Compare commits

...
Author SHA1 Message Date
Jon Saad-FalconandClaude Opus 4.8 3e2f4bcdb4 feat(core): consolidate all state under a single env-aware home directory (#462) (#549)
Previously `core/config.py` defined `DEFAULT_CONFIG_DIR = Path.home() /
".openjarvis"` as a by-value module constant imported into ~45 modules, and 34
modules hardcoded `Path.home() / ".openjarvis"` directly. The installer honored
`OPENJARVIS_HOME` but the Python runtime ignored it, producing a split-brain
layout (some modules honored the override, the core config dir did not). Eval
dataset caches also scattered into `~/.cache/<benchmark>`.

This introduces a single env-aware resolver in `openjarvis/core/paths.py` and
routes every state/config/cache path through it. OpenJarvis now keeps ALL of
its state under ONE root, resolved in priority order:

  1. $OPENJARVIS_HOME
  2. $XDG_DATA_HOME/openjarvis   (single nested dir, when XDG_DATA_HOME is set)
  3. ~/.openjarvis               (default — unchanged, so existing installs are
                                  untouched and no data migration is required)

Implementation:
- New `core/paths.py`: get_config_dir / get_config_path / get_data_dir /
  get_cache_dir, with a source-tree rejection guard (fails loudly per
  REVIEW.md if the root resolves inside the repo).
- `core/config.py`: DEFAULT_CONFIG_DIR / DEFAULT_CONFIG_PATH are now resolved
  via the env-aware resolver at import (real attributes, so existing
  monkeypatch.setattr-based tests keep working). All dataclass field defaults
  that pointed at ~/.openjarvis converted to default_factory so they honor the
  override at instantiation.
- Routed all 34 hardcoders plus several string-literal escapees the original
  audit missed: prompt_loader / description_loader (were OPENJARVIS_HOME-only,
  no XDG), swebench_harness cache, tools/{memory,skill,user_profile}_manage
  defaults, server trace.db fallbacks, doctor_cmd hints.
- spec_search storage/paths now delegates to the unified resolver (gains XDG);
  its ConfigurationError is aliased to the core one.
- Eval dataset caches moved from ~/.cache/<name> to <root>/cache/<name>
  (~/.cache/huggingface left alone — it is HF's own cache).
- Docs + installer comment + `jarvis config path` to show resolved dirs.

Read-only macOS connectors and OS service files (LaunchAgents/systemd) are
intentionally left untouched.

Fixes #462

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:32:02 -07:00
github-actions[bot] a35b21195f chore: update clone traffic data [skip ci] 2026-06-15 08:03:30 +00:00
Jon Saad-FalconandClaude Opus 4.8 f9d1bc8c27 fix(connectors): complete Google OAuth and register Drive in Data Sources (fixes #512) (#548)
Pasting a Google Client ID / Secret never completed OAuth: Drive (and its
Google siblings) accepted the credentials, showed no error, opened no browser,
and never appeared in Data Sources. Root cause is three coupled defects, all
reproduced at the unit level against main with a FastAPI TestClient (no Google
creds, network-free):

(A/B) POST /connect routed a `client_id:client_secret` pair into the
  connector's handle_callback, which spawned a daemon thread that popped a
  browser and ran its own localhost:8789 callback server. That thread fails
  silently in the bundled desktop context (`except Exception: pass`), so the
  connector never gained an access_token; /connect returned status "pending"
  and the UI's 20x2s poll timed out with no error.
  Fix: in POST /connect, an OAuth `client_id:client_secret` pair now persists
  the client credentials to every Google credential file and returns an
  `oauth_required` directive pointing at the in-process server flow, instead of
  the silent background thread. The Google connectors' handle_callback no longer
  spawns the browser thread for the pair case — it only persists the creds; the
  server's /oauth/start -> /oauth/callback owns the consent round-trip.

(C) The would-be-correct server flow was itself broken: under
  `from __future__ import annotations` plus a `Request` import local to the
  router factory, FastAPI could not resolve the stringized `request: Request`
  annotation. /oauth/start returned HTTP 422 (request mis-bound as a query
  param) and /oauth/callback injected None -> AttributeError on
  `request.base_url`. Fix: import `Request` at module scope and make the
  callback's `request` a required injected dependency.

A malformed/blank client pair now raises HTTP 400 with the provider setup URL
instead of a perpetual silent "pending" (REVIEW.md silent-failure discipline).

Frontend: DataSourcesPage now opens the server OAuth window when /connect
returns `oauth_required`, then polls until connected; connect errors surface the
backend detail; the Drive setup steps document the "Web application" OAuth
client + server-callback redirect URI the in-process flow requires.

Tests (run on the main venv, hermetic — no ~/.openjarvis pollution):
- test_oauth_flow.py: the three handle_callback tests now assert NO browser is
  opened and only client creds are persisted (was: assert background flow ran).
- test_connectors_router_oauth.py (new): reproduces + fixes all three defects via
  TestClient with mocked token exchange; parametrized over gdrive/gcalendar/
  gcontacts/gmail/google_tasks to prove the shared OAuth path is fixed for every
  sibling and that a single consent writes the access_token to all six Google
  credential files and flips is_connected() to True.
Full tests/connectors suite: 355 passed.

Relationship to PR #510: #510 rewrites all of these files (account-scoped
retrieval) but still carries all three defects. This fix is intentionally scoped
to the OAuth path and does not modify oauth.py, to minimize collision. A
maintainer can either merge this and rebase #510 on top, or port these changes
into #510. See PR body for details.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:55:22 -07:00
Aditya MalikandJon Saad-Falcon dfa908c358 Adopt hatch-vcs dynamic versioning (reworks autotag, pypi-publish, desktop) (#538)
* build: adopt hatch-vcs dynamic versioning (#526)

Replace the static `version = "1.0.2"` with `dynamic = ["version"]` and
derive the version from git tags via hatch-vcs, so source and editable
checkouts report their true git describe version (e.g.
1.0.3.dev110+g<sha>) instead of a stale constant.

Config notes:
- Exclude .dev/.rc/desktop-* tags from derivation. setuptools_scm cannot
  bump custom .devN tags, so the base is taken from the latest plain
  release tag (vX.Y.Z) and the dev distance from commit count.
- Add fallback_version so builds without a git checkout (shallow CI
  clones, Docker COPY src/, source-zip installs) resolve to a sentinel
  instead of hard-failing. CI release builds inject the exact version via
  SETUPTOOLS_SCM_PRETEND_VERSION.

* ci(autotag): derive dev base from the latest release tag (#526)

pyproject no longer carries a static version, so read the base from the
latest plain release tag (vX.Y.Z) reachable from HEAD instead of grepping
pyproject. .dev/.rc/desktop-* tags are excluded so they cannot be mistaken
for the release base. The computed tag (vX.Y.Z.devN) is unchanged.

* ci(pypi-publish): pin build version from tag, drop sed injection (#526)

With dynamic versioning there is no static line to sed. Pin the exact
build version from the pushed tag via SETUPTOOLS_SCM_PRETEND_VERSION so
the published version equals the tag. This is required, not cosmetic: a
naive hatch-vcs build emits 1.0.3.devN+g<sha>, and PyPI rejects local
version segments on upload.

Also add a dry_run input that targets TestPyPI instead of PyPI, for
validating the release path without a production upload.

* ci(desktop): derive dispatch-fallback version from release tag (#526)

The workflow_dispatch fallback grepped the now-removed static pyproject
version. Derive its base from the latest release tag instead (matching
autotag), and give the build-and-release checkout full history and tags
so the derivation works.

---------

Co-authored-by: Jon Saad-Falcon <jonsaadfalcon@gmail.com>
2026-06-14 19:35:17 -07:00
71 changed files with 1343 additions and 347 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"label": "Git Clones",
"message": "117,047",
"message": "118,590",
"color": "green",
"namedLogo": "git"
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"total_clones": 117047,
"last_updated": "2026-06-14T07:38:47Z",
"total_clones": 118590,
"last_updated": "2026-06-15T08:03:30Z",
"daily": {
"2026-03-27": 2189,
"2026-03-28": 1874,
@@ -80,6 +80,7 @@
"2026-06-10": 1310,
"2026-06-11": 2564,
"2026-06-12": 1313,
"2026-06-13": 2804
"2026-06-13": 2804,
"2026-06-14": 1543
}
}
+10 -4
View File
@@ -21,14 +21,20 @@ jobs:
id: version
run: |
set -euo pipefail
# Base version is the next patch above whatever is in pyproject.toml.
# Base version is the next patch above the latest plain release tag
# (vX.Y.Z) reachable from HEAD. pyproject.toml no longer carries a
# static version (#526 switched it to hatch-vcs), so the release tag
# is the source of truth. `.devN`/`.rcN`/`desktop-*` tags are excluded
# so they can't be mistaken for the release base.
# Any future manual `X.Y.Z` release will outrank every `X.Y.Z.devN`
# autotag — PEP 440 sorts dev releases strictly below the final.
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
if [[ -z "$BASE" ]]; then
echo "::error::Could not parse version from pyproject.toml"
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
if [[ -z "$LATEST_RELEASE" ]]; then
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
exit 1
fi
BASE="${LATEST_RELEASE#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
+15 -1
View File
@@ -114,6 +114,11 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Full history + tags so the workflow_dispatch fallback in
# "Determine release info" can derive the dev version from the
# latest release tag (#526).
fetch-depth: 0
- name: Install system dependencies (Linux)
if: matrix.platform == 'ubuntu-22.04'
@@ -183,7 +188,16 @@ jobs:
# workflow_dispatch fallback (manual UI dispatch without --ref).
# Derive a PEP 440 dev version aligned with autotag.yml so we
# don't burn the X.Y.Z release-version namespace.
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
# pyproject.toml no longer carries a static version (#526), so the
# base comes from the latest plain release tag (vX.Y.Z), matching
# autotag.yml. .dev/.rc/desktop-* tags are excluded.
LATEST_RELEASE=$(git tag --list 'v[0-9]*' --merged HEAD \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
if [[ -z "$LATEST_RELEASE" ]]; then
echo "::error::No release tag (vX.Y.Z) reachable from HEAD"
exit 1
fi
BASE="${LATEST_RELEASE#v}"
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
+30 -11
View File
@@ -12,6 +12,11 @@ on:
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
required: false
type: string
dry_run:
description: 'Dry run: build + validate, then publish to TestPyPI instead of PyPI (no production upload).'
required: false
default: false
type: boolean
permissions:
contents: read
@@ -67,27 +72,41 @@ jobs:
exit 1
}
- name: Set version from tag
- name: Resolve build version from tag
env:
REF: ${{ steps.ref.outputs.ref }}
run: |
set -euo pipefail
# Strip leading "v" if present (e.g. v1.0.2.dev500 -> 1.0.2.dev500)
# Strip leading "v" (e.g. v1.0.3.dev825 -> 1.0.3.dev825).
VERSION="${REF#v}"
if [[ -z "$VERSION" ]]; then
echo "::error::Could not resolve version from ref '$REF'"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
echo "::error::ref '$REF' is not a version tag (expected vX.Y.Z[.devN]); pass -f tag=vX.Y.Z"
exit 1
fi
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
# Sanity check the substitution actually took
grep -q "^version = \"${VERSION}\"" pyproject.toml || {
echo "::error::sed failed to update pyproject.toml version"
exit 1
}
echo "Building version $VERSION"
# pyproject.toml is now dynamic = ["version"] via hatch-vcs (#526), so
# there is no static line to sed. setuptools_scm cannot bump custom
# `.devN` tags, so we pin the exact build version explicitly — the
# published version always equals the pushed tag.
echo "SETUPTOOLS_SCM_PRETEND_VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "Building version ${VERSION}"
- name: Build package
run: uv build
- name: Publish to TestPyPI (dry run)
if: ${{ inputs.dry_run }}
env:
UV_PUBLISH_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${UV_PUBLISH_TOKEN:-}" ]]; then
echo "::warning::TEST_PYPI_API_TOKEN is not set — skipping the TestPyPI upload."
echo "Build + twine check passed, which validated version derivation and packaging end to end."
echo "To exercise a real upload, add a TEST_PYPI_API_TOKEN secret (or a TestPyPI trusted publisher)."
exit 0
fi
uv publish --publish-url https://test.pypi.org/legacy/
- name: Publish to PyPI
if: ${{ !inputs.dry_run }}
run: uv publish
+40
View File
@@ -17,6 +17,46 @@ The configuration file lives at:
OpenJarvis creates the `~/.openjarvis/` directory and populates it with a default config when you run `jarvis init`.
## Relocating the OpenJarvis directory
OpenJarvis keeps **all** of its state — config, databases, caches, logs,
credentials, skills, recipes, connectors — under a **single root** so it never
clutters your home directory beyond one folder. By default that root is
`~/.openjarvis`, but you can move it.
The root is resolved in priority order:
1. **`$OPENJARVIS_HOME`** — explicit override. Honored by both the installer
and the Python runtime.
2. **`$XDG_DATA_HOME/openjarvis`** — used when `$XDG_DATA_HOME` is set (a single
`openjarvis` directory nested under it, per the XDG Base Directory spec).
3. **`~/.openjarvis`** — the default. With no environment variables set, the
resolved path is exactly this, so existing installs are untouched.
```bash
# Relocate the whole install + runtime tree at install time:
OPENJARVIS_HOME=~/apps/openjarvis curl -fsSL https://open-jarvis.github.io/OpenJarvis/install.sh | bash
# Or for a single run / your shell profile:
export OPENJARVIS_HOME=~/apps/openjarvis
```
Confirm where your data lives with:
```bash
jarvis config path
```
!!! note "Migration"
Because the default is unchanged, **no data migration is required** for
existing installs. If you set `OPENJARVIS_HOME` (or `XDG_DATA_HOME`) on a
machine that already has data in `~/.openjarvis`, OpenJarvis will look in
the new location and not see your old data — move it yourself if you want
to keep it: `mv ~/.openjarvis "$OPENJARVIS_HOME"`.
`$OPENJARVIS_CONFIG` still points at an explicit `config.toml` file
independently of the root, if you need to override just the config file path.
## Generating Configuration
### First-Time Setup
+34 -3
View File
@@ -1,5 +1,5 @@
import { getBase } from './api';
import type { ConnectorInfo, SyncStatus, ConnectRequest } from '../types/connectors';
import type { ConnectorInfo, SyncStatus, ConnectRequest, ConnectResponse } from '../types/connectors';
// ---------------------------------------------------------------------------
// Connectors API
@@ -18,16 +18,47 @@ export async function getConnector(id: string): Promise<ConnectorInfo> {
return res.json();
}
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectorInfo> {
export async function connectSource(id: string, req: ConnectRequest): Promise<ConnectResponse> {
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req),
});
if (!res.ok) throw new Error(`Failed to connect ${id}: ${res.status}`);
if (!res.ok) {
// Surface the backend's actionable detail (e.g. malformed Client ID /
// Secret) instead of a bare status code so the UI can render it.
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `Failed to connect ${id}: ${res.status}`);
}
return res.json();
}
/** Open the server-side OAuth consent flow in a popup and resolve once the
* connector reports connected (or reject on timeout). Reused for any OAuth
* connector whose /connect returned `oauth_required` (issue #512). */
export function startServerOAuth(id: string, oauthStartPath?: string): Promise<void> {
const path = oauthStartPath || `/v1/connectors/${encodeURIComponent(id)}/oauth/start`;
window.open(`${getBase()}${path}`, '_blank', 'width=600,height=700');
return new Promise((resolve, reject) => {
const interval = setInterval(async () => {
try {
const info = await getConnector(id);
if (info.connected) {
clearInterval(interval);
clearTimeout(timer);
resolve();
}
} catch {
// ignore transient polling errors
}
}, 2000);
const timer = setTimeout(() => {
clearInterval(interval);
reject(new Error('Authorization timed out — please try again.'));
}, 180000);
});
}
export async function disconnectSource(id: string): Promise<void> {
const res = await fetch(`${getBase()}/v1/connectors/${encodeURIComponent(id)}/disconnect`, {
method: 'POST',
+14 -2
View File
@@ -24,7 +24,7 @@ import {
import type { LucideIcon } from 'lucide-react';
import { SOURCE_CATALOG } from '../types/connectors';
import type { ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync } from '../lib/connectors-api';
import { listConnectors, connectSource, disconnectSource, getSyncStatus, triggerSync, startServerOAuth } from '../lib/connectors-api';
import type { SyncStatus } from '../types/connectors';
// ---------------------------------------------------------------------------
@@ -673,7 +673,19 @@ function DataSourcesSection() {
setConnectStage('Connecting...');
setConnectError('');
try {
await connectSource(id, req);
const resp = await connectSource(id, req);
// OAuth connectors (Google Drive/Calendar/Contacts/Gmail/Tasks): pasting
// a Client ID / Secret only registers the app credentials. The backend
// returns `oauth_required` with the path to the in-process consent flow,
// which is the only path that actually mints an access token. Open it now
// and wait for the callback to flip the connector to connected. Without
// this the connector would stay "pending" forever — the exact #512 bug.
if (resp.status === 'oauth_required') {
setConnectStage('Opening Google sign-in...');
await startServerOAuth(id, resp.oauth_start);
}
setConnectStage('Connected! Starting sync...');
// Wait for connector to show as connected
+15 -2
View File
@@ -55,6 +55,19 @@ export interface ConnectRequest {
password?: string;
}
/** Response from POST /v1/connectors/{id}/connect.
* For OAuth connectors, pasting a Client ID / Secret pair only registers the
* app credentials; the backend returns `status: "oauth_required"` plus an
* `oauth_start` path the UI must open to run the browser consent flow that
* actually mints an access token (see issue #512). */
export interface ConnectResponse {
connector_id: string;
connected: boolean;
status: "connected" | "pending" | "oauth_required" | "disconnected";
oauth_start?: string;
sync_status?: string | null;
}
export type WizardStep = "pick" | "connect" | "ingest" | "ready";
// Backward-compatible alias
@@ -257,12 +270,12 @@ export const SOURCE_CATALOG: ConnectorMeta[] = [
urlLabel: 'Enable Drive API',
},
{
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Desktop app" → click "Create"',
label: 'Create OAuth credentials: go to Credentials (link below) → click "+ Create Credentials" → choose "OAuth client ID" → Application type: "Web application". Under "Authorized redirect URIs" add this server\'s callback (e.g. http://localhost:1313/v1/connectors/gdrive/oauth/callback — match the host/port your OpenJarvis server is bound to) → click "Create".',
url: 'https://console.cloud.google.com/apis/credentials',
urlLabel: 'Open Credentials',
},
{
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below. (If you miss it, click the download icon next to your OAuth client to see them again)',
label: 'A dialog will show your Client ID and Client Secret. Copy both and paste them below, then click Connect — a Google sign-in window opens to finish authorization. (If you miss the dialog, click the download icon next to your OAuth client to see them again.)',
},
],
inputFields: [
+26 -2
View File
@@ -1,10 +1,10 @@
[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "OpenJarvis"
version = "1.0.2"
dynamic = ["version"]
description = "OpenJarvis — modular AI assistant backend with composable intelligence primitives"
readme = "README.md"
# Upper bound: numpy 2.2.x (pinned transitively via datasets/pandas) ships no
@@ -154,6 +154,30 @@ Issues = "https://github.com/open-jarvis/OpenJarvis/issues"
jarvis = "openjarvis.cli:main"
openjarvis-eval = "openjarvis.evals.cli:main"
# Version is derived from git tags by hatch-vcs (see #526). For source/editable
# checkouts this yields the true `git describe` version (e.g. 1.0.3.dev109+g<sha>)
# rather than a stale static string. CI release builds override this with
# SETUPTOOLS_SCM_PRETEND_VERSION so the published version equals the pushed tag.
#
# setuptools_scm cannot bump custom `.devN` tags (only `.dev0`), so the autotag
# `vX.Y.Z.devN` tags are deliberately EXCLUDED from version derivation here; the
# base is taken from the latest plain release tag (vX.Y.Z) and the dev distance
# is computed from commit count since that release.
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
tag_regex = '^v(?P<version>[0-9]+\.[0-9]+\.[0-9]+)$'
git_describe_command = [
"git", "describe", "--dirty", "--tags", "--long",
"--match", "v[0-9]*", "--exclude", "*dev*", "--exclude", "*rc*", "--exclude", "desktop-*",
]
# Builds without a git checkout (e.g. the `COPY src/ src/` Docker stages, which
# never include .git) can't run `git describe`. Without a fallback that would
# hard-fail the build. Mirror the runtime sentinel in src/openjarvis/__init__.py.
# Such builds can inject the real version via SETUPTOOLS_SCM_PRETEND_VERSION.
fallback_version = "0.0.0+unknown"
[tool.hatch.build.targets.wheel]
packages = ["src/openjarvis"]
+8
View File
@@ -210,6 +210,14 @@ if ! command -v python3 >/dev/null 2>&1; then
fi
# ---- env ----
# OpenJarvis keeps ALL of its state (install tree + runtime data, configs,
# databases, caches, logs) under a single root so it never clutters $HOME
# beyond one directory. Relocate it by exporting OPENJARVIS_HOME before
# running the installer, e.g.:
# OPENJARVIS_HOME=~/apps/openjarvis curl ... | bash
# The Python runtime honors the same override (and, when OPENJARVIS_HOME is
# unset, $XDG_DATA_HOME/openjarvis if XDG_DATA_HOME is set). With nothing set
# the root is ~/.openjarvis, so existing installs are untouched.
OPENJARVIS_HOME="${OPENJARVIS_HOME:-$HOME/.openjarvis}"
OPENJARVIS_REPO_URL="${OPENJARVIS_REPO_URL:-https://github.com/open-jarvis/OpenJarvis.git}"
SRC_DIR="$OPENJARVIS_HOME/src"
+2 -1
View File
@@ -19,6 +19,7 @@ from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, BaseAgent
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import ToolResult
from openjarvis.engine._stubs import InferenceEngine
@@ -103,7 +104,7 @@ class ClaudeCodeAgent(BaseAgent):
"Install it from https://nodejs.org/ or via your package manager."
)
dest = Path.home() / ".openjarvis" / "claude_code_runner"
dest = get_config_dir() / "claude_code_runner"
dest.mkdir(parents=True, exist_ok=True)
# Copy runner files if missing or outdated
+3 -1
View File
@@ -9,6 +9,8 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
from openjarvis.core.paths import get_config_dir
@dataclass
class DigestArtifact:
@@ -30,7 +32,7 @@ class DigestStore:
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(Path.home() / ".openjarvis" / "digest.db")
db_path = str(get_config_dir() / "digest.db")
self._db_path = db_path
self._conn = sqlite3.connect(db_path, check_same_thread=False)
self._conn.execute("PRAGMA journal_mode=WAL")
+85 -48
View File
@@ -38,13 +38,14 @@ except ModuleNotFoundError:
from openjarvis.agents._stubs import AgentContext, AgentResult
from openjarvis.agents.hybrid._energy import EnergyCollector
from openjarvis.agents.hybrid._prompts import format_prompt as _format_prompt
from openjarvis.core.paths import get_config_dir
PACKAGE_DIR = Path(__file__).parent
DEFAULT_REGISTRY_DIR = PACKAGE_DIR / "registry"
DEFAULT_EXPERIMENTS_DIR = Path(
os.environ.get(
"OPENJARVIS_HYBRID_EXPERIMENTS_DIR",
Path.home() / ".openjarvis" / "experiments" / "hybrid",
get_config_dir() / "experiments" / "hybrid",
)
)
DEFAULT_SUBSETS_DIR = DEFAULT_EXPERIMENTS_DIR / "subsets"
@@ -122,6 +123,7 @@ def load_registry(registry_dir: Optional[Path] = None) -> Dict[str, Dict[str, An
# ---------- Bench dispatch ----------
def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
"""GAIA validation. Each task is a dict with `task_id` + `question`."""
from openjarvis.evals.datasets.gaia import GAIADataset
@@ -137,12 +139,14 @@ def _load_gaia_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
# id round-trip.
md = rec.metadata or {}
task_id = md.get("task_id") or rec.record_id
out.append({
"task_id": task_id,
"question": md.get("question", rec.problem),
"reference": rec.reference,
"metadata": dict(md),
})
out.append(
{
"task_id": task_id,
"question": md.get("question", rec.problem),
"reference": rec.reference,
"metadata": dict(md),
}
)
return out
@@ -155,19 +159,21 @@ def _load_swebench_tasks(n: Optional[int]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for rec in ds.iter_records():
md = rec.metadata or {}
out.append({
"task_id": md.get("instance_id", rec.record_id),
"repo": md.get("repo", ""),
"base_commit": md.get("base_commit", ""),
"problem_statement": md.get("problem_statement", rec.problem),
"hints_text": md.get("hints_text", ""),
"test_patch": md.get("test_patch", ""),
"FAIL_TO_PASS": md.get("FAIL_TO_PASS", []),
"PASS_TO_PASS": md.get("PASS_TO_PASS", []),
"version": md.get("version"),
"reference": rec.reference,
"metadata": dict(md),
})
out.append(
{
"task_id": md.get("instance_id", rec.record_id),
"repo": md.get("repo", ""),
"base_commit": md.get("base_commit", ""),
"problem_statement": md.get("problem_statement", rec.problem),
"hints_text": md.get("hints_text", ""),
"test_patch": md.get("test_patch", ""),
"FAIL_TO_PASS": md.get("FAIL_TO_PASS", []),
"PASS_TO_PASS": md.get("PASS_TO_PASS", []),
"version": md.get("version"),
"reference": rec.reference,
"metadata": dict(md),
}
)
return out
@@ -206,7 +212,9 @@ def _load_subset_file(subset_path: str) -> Dict[str, Any]:
f"subset {p.name} has no 'task_ids' field; got keys {list(data.keys())}"
)
return data
raise ValueError(f"subset {p.name} must be a list or dict; got {type(data).__name__}")
raise ValueError(
f"subset {p.name} must be a list or dict; got {type(data).__name__}"
)
def _apply_subset(
@@ -332,7 +340,11 @@ def _score_swebench(
patch = extract_patch(answer)
if patch is None:
return {"success": False, "score": 0.0, "details": {"reason": "no_patch_extracted"}}
return {
"success": False,
"score": 0.0,
"details": {"reason": "no_patch_extracted"},
}
record = EvalRecord(
record_id=task["task_id"],
@@ -368,6 +380,7 @@ def score(
# ---------- Cell run ----------
def _cell_dir(cell_name: str, root: Path) -> Path:
d = root / cell_name
d.mkdir(parents=True, exist_ok=True)
@@ -440,8 +453,10 @@ def _error_row(task: Dict[str, Any], t0: float, error: str) -> Dict[str, Any]:
return {
"task_id": task["task_id"],
"answer": "",
"tokens_local": 0, "tokens_cloud": 0,
"cost_usd": 0.0, "latency_s": time.time() - t0,
"tokens_local": 0,
"tokens_cloud": 0,
"cost_usd": 0.0,
"latency_s": time.time() - t0,
"web_search_uses": 0,
"tool_calls": 0,
"n_cloud_calls": 0,
@@ -456,11 +471,13 @@ def _run_one_inner(
) -> Dict[str, Any]:
"""Run the agent on one task. Returns a hybrid-shape row."""
prompt = _format_prompt(task)
ctx = AgentContext(metadata={
"task": task,
"task_id": task["task_id"],
"log_dir": log_dir,
})
ctx = AgentContext(
metadata={
"task": task,
"task_id": task["task_id"],
"log_dir": log_dir,
}
)
t0 = time.time()
try:
result: AgentResult = agent.run(prompt, ctx)
@@ -534,11 +551,12 @@ def _run_one(
if worker.is_alive():
print(
f"[timeout] task={task['task_id']} exceeded "
f"{task_timeout_s/60:.1f}m — abandoning worker, recording error row",
f"{task_timeout_s / 60:.1f}m — abandoning worker, recording error row",
flush=True,
)
return _error_row(
task, t0,
task,
t0,
f"TaskTimeout: task exceeded the {task_timeout_s:.0f}s hybrid "
"per-task wall-clock cap (likely a hung network or Modal-harness "
"call); worker thread abandoned, task left for resume.",
@@ -558,7 +576,7 @@ def _heartbeat(done: int, total: int, row: Dict[str, Any], t_start: float) -> No
print(
f"[{done}/{total}] {ok} task={row['task_id']} score={sc_str} "
f"local={row['tokens_local']} cloud={row['tokens_cloud']} "
f"${row['cost_usd']:.3f} {row['latency_s']:.1f}s eta={eta/60:.1f}m",
f"${row['cost_usd']:.3f} {row['latency_s']:.1f}s eta={eta / 60:.1f}m",
flush=True,
)
@@ -644,9 +662,9 @@ def _write_summary(
summary_path.write_text(json.dumps(summary, indent=2))
print(
f"[summary] {cell_name}: n={n_done}/{cell['n']} err={n_err} "
f"acc={acc:.3f} cost=${total_cost:.2f} time={wall/60:.1f}m "
f"energy={energy_j/1000:.1f}kJ "
f"(session +{elapsed/60:.1f}m +{energy_j_session/1000:.1f}kJ, "
f"acc={acc:.3f} cost=${total_cost:.2f} time={wall / 60:.1f}m "
f"energy={energy_j / 1000:.1f}kJ "
f"(session +{elapsed / 60:.1f}m +{energy_j_session / 1000:.1f}kJ, "
f"processed={n_processed})",
flush=True,
)
@@ -664,8 +682,11 @@ def run_cell(
out_dir = _cell_dir(cell_name, out_root)
with _cell_lock(out_dir, cell_name):
_run_cell_locked(
cell_name, cell, out_dir,
do_score=do_score, resume=resume,
cell_name,
cell,
out_dir,
do_score=do_score,
resume=resume,
)
@@ -727,7 +748,7 @@ def _run_cell_locked(
mcfg = cell.get("method_cfg") or {}
task_timeout_s = float(mcfg.get("task_timeout_s", DEFAULT_TASK_TIMEOUT_S))
if task_timeout_s > 0:
print(f"[task-timeout] {task_timeout_s/60:.1f}m per task", flush=True)
print(f"[task-timeout] {task_timeout_s / 60:.1f}m per task", flush=True)
agent = _build_agent(cell)
@@ -739,18 +760,25 @@ def _run_cell_locked(
def _process(task: Dict[str, Any]) -> None:
row = _run_one(
agent, cell["bench"], task, log_dir,
agent,
cell["bench"],
task,
log_dir,
task_timeout_s=task_timeout_s,
)
scored: Optional[Dict[str, Any]] = None
if do_score and row.get("error") is None:
try:
scored = score(
cell["bench"], task, row["answer"], cell_name=cell_name,
cell["bench"],
task,
row["answer"],
cell_name=cell_name,
)
except Exception as e:
scored = {
"success": False, "score": 0.0,
"success": False,
"score": 0.0,
"details": {"score_error": str(e)},
}
full_row = {**row, "score": scored}
@@ -789,9 +817,7 @@ def _run_cell_locked(
while not watchdog_stop.wait(60.0):
try:
cur = (
results_path.stat().st_mtime
if results_path.exists()
else last_seen
results_path.stat().st_mtime if results_path.exists() else last_seen
)
except Exception:
cur = last_seen
@@ -830,7 +856,11 @@ def _run_cell_locked(
watchdog_stop.set()
_write_summary(
out_dir, cell_name, cell, tasks, t_start,
out_dir,
cell_name,
cell,
tasks,
t_start,
n_processed=len(pending),
energy_j_session=energy.energy_j_total,
)
@@ -838,6 +868,7 @@ def _run_cell_locked(
# ---------- CLI ----------
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(
prog="python -m openjarvis.agents.hybrid.runner",
@@ -855,13 +886,18 @@ def main(argv: Optional[List[str]] = None) -> int:
help="Override experiments output root.",
)
p.add_argument("--no-score", action="store_true", help="Skip scoring.")
p.add_argument("--no-resume", action="store_true", help="Don't resume from results.jsonl.")
p.add_argument(
"--no-resume", action="store_true", help="Don't resume from results.jsonl."
)
args = p.parse_args(argv)
reg_dir = Path(args.registry_dir) if args.registry_dir else None
cells = load_registry(reg_dir)
if not cells:
print(f"[error] no cells found in {reg_dir or DEFAULT_REGISTRY_DIR}", file=sys.stderr)
print(
f"[error] no cells found in {reg_dir or DEFAULT_REGISTRY_DIR}",
file=sys.stderr,
)
return 2
if args.cell not in cells:
print(
@@ -871,7 +907,8 @@ def main(argv: Optional[List[str]] = None) -> int:
return 2
root = Path(args.root) if args.root else None
run_cell(
args.cell, cells[args.cell],
args.cell,
cells[args.cell],
do_score=not args.no_score,
resume=not args.no_resume,
root=root,
+3 -2
View File
@@ -11,10 +11,11 @@ import logging
import sqlite3
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import uuid4
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
_CREATE_AGENTS = """\
@@ -537,7 +538,7 @@ class AgentManager:
pass
# User templates
user_dir = Path("~/.openjarvis/templates").expanduser()
user_dir = get_config_dir() / "templates"
if user_dir.is_dir():
for f in user_dir.glob("*.toml"):
try:
+3 -2
View File
@@ -13,6 +13,7 @@ from typing import Any, List, Optional
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.agents.digest_store import DigestArtifact, DigestStore
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall
@@ -21,7 +22,7 @@ def _load_persona(persona_name: str) -> str:
"""Load a persona prompt file by name."""
search_paths = [
Path("configs/openjarvis/prompts/personas") / f"{persona_name}.md",
Path.home() / ".openjarvis" / "prompts" / "personas" / f"{persona_name}.md",
get_config_dir() / "prompts" / "personas" / f"{persona_name}.md",
]
for p in search_paths:
if p.exists():
@@ -202,7 +203,7 @@ class MorningDigestAgent(ToolUsingAgent):
tts_text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", tts_text)
tts_text = tts_text.strip()
output_dir = str(Path.home() / ".openjarvis" / "digests")
output_dir = str(get_config_dir() / "digests")
tts_call = ToolCall(
id="digest-tts-1",
name="text_to_speech",
+3 -2
View File
@@ -43,6 +43,7 @@ from typing import Any, Dict, List, Optional, Set
from openjarvis.agents._stubs import AgentContext, AgentResult, ToolUsingAgent
from openjarvis.core.config import load_config
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import AgentRegistry
from openjarvis.core.types import Message, Role, ToolCall
from openjarvis.tools.approval_store import (
@@ -342,8 +343,8 @@ class ProactiveAgent(ToolUsingAgent):
return self._approval_store
def _build_system_prompt(self) -> str:
user_md = _load_md_file(Path.home() / ".openjarvis" / "USER.md")
memory_md = _load_md_file(Path.home() / ".openjarvis" / "MEMORY.md")
user_md = _load_md_file(get_config_dir() / "USER.md")
memory_md = _load_md_file(get_config_dir() / "MEMORY.md")
now = datetime.now()
context_block = ""
if user_md or memory_md:
+4 -3
View File
@@ -15,16 +15,17 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
def _openjarvis_home() -> Path:
"""Resolve $OPENJARVIS_HOME, defaulting to ~/.openjarvis."""
return Path(os.environ.get("OPENJARVIS_HOME", "~/.openjarvis")).expanduser()
"""Resolve the OpenJarvis root, honoring OPENJARVIS_HOME / XDG_DATA_HOME."""
return get_config_dir()
def load_system_prompt_override(agent_name: str) -> str | None:
+3 -1
View File
@@ -18,11 +18,13 @@ import time
from pathlib import Path
from typing import Any, Dict, List
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
_DEFAULT_DB_PATH = str(Path.home() / "Library" / "Messages" / "chat.db")
_POLL_INTERVAL = 5
_PID_FILE = str(Path.home() / ".openjarvis" / "imessage-agent.pid")
_PID_FILE = str(get_config_dir() / "imessage-agent.pid")
def poll_new_messages(
+3 -1
View File
@@ -14,9 +14,11 @@ import sys
from pathlib import Path
from typing import Any
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
_PID_FILE = str(Path.home() / ".openjarvis" / "slack-daemon.pid")
_PID_FILE = str(get_config_dir() / "slack-daemon.pid")
def _to_slack_fmt(text: str) -> str:
+2 -1
View File
@@ -22,6 +22,7 @@ from openjarvis.channels._stubs import (
ChannelStatus,
)
from openjarvis.core.events import EventBus, EventType
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import ChannelRegistry
logger = logging.getLogger(__name__)
@@ -38,7 +39,7 @@ if not _BRIDGE_SRC.exists():
)
# Default runtime directory (npm install + auth state).
_DEFAULT_RUNTIME_DIR = Path.home() / ".openjarvis" / "whatsapp_baileys_bridge"
_DEFAULT_RUNTIME_DIR = get_config_dir() / "whatsapp_baileys_bridge"
@ChannelRegistry.register("whatsapp_baileys")
+4 -2
View File
@@ -9,9 +9,11 @@ import sys
import time
from pathlib import Path
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
_CACHE_PATH = Path("~/.openjarvis/version-check.json").expanduser()
_CACHE_PATH = get_config_dir() / "version-check.json"
_CACHE_TTL = 86400 # 24 hours
_PYPI_API = "https://pypi.org/pypi/openjarvis/json"
@@ -21,7 +23,7 @@ def _config_path() -> Path:
override = os.environ.get("OPENJARVIS_CONFIG")
if override:
return Path(override).expanduser()
return Path("~/.openjarvis/config.toml").expanduser()
return get_config_dir() / "config.toml"
# Commands that surface the "new version available" nudge. We deliberately
+9 -11
View File
@@ -12,15 +12,12 @@ from rich.table import Table
def _get_manager():
"""Get or create the AgentManager singleton."""
from pathlib import Path
from openjarvis.agents.manager import AgentManager
from openjarvis.core.config import load_config
from openjarvis.core.paths import get_config_dir
config = load_config()
db_path = config.agent_manager.db_path or str(
Path("~/.openjarvis/agents.db").expanduser()
)
db_path = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
return AgentManager(db_path=db_path)
@@ -273,6 +270,7 @@ def search(agent_id: str, query: str, limit: int) -> None:
console = Console(stderr=True)
try:
from openjarvis.core.config import load_config
from openjarvis.core.paths import get_config_dir
from openjarvis.traces.store import TraceStore
config = load_config()
@@ -281,7 +279,7 @@ def search(agent_id: str, query: str, limit: int) -> None:
if not agent:
console.print(f"[red]Agent not found: {agent_id}[/red]")
return
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
store = TraceStore(config.traces.db_path or str(get_config_dir() / "traces.db"))
results = store.search(query, agent=agent["name"], limit=limit)
if not results:
console.print("[dim]No results.[/dim]")
@@ -545,8 +543,7 @@ def run_agent(agent_id):
updated = manager.get_agent(agent_id)
runs = updated.get("total_runs", 0)
console.print(
f"[green]✓[/green] Tick complete. "
f"Status: {updated['status']}, runs: {runs}"
f"[green]✓[/green] Tick complete. Status: {updated['status']}, runs: {runs}"
)
# Print the agent's actual output. summary_memory holds the latest tick's
@@ -662,6 +659,7 @@ def trace(agent_id, run_number, limit):
import datetime
from openjarvis.core.config import load_config
from openjarvis.core.paths import get_config_dir
from openjarvis.traces.store import TraceStore
manager = _get_manager()
@@ -671,7 +669,7 @@ def trace(agent_id, run_number, limit):
raise SystemExit(1)
config = load_config()
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
store = TraceStore(config.traces.db_path or str(get_config_dir() / "traces.db"))
traces = store.list_traces(agent=agent_id, limit=limit)
if not traces:
@@ -841,8 +839,8 @@ def ask(agent_id, message, auto_approve):
if auto_approve:
executor._confirm_callback = lambda _prompt: True
else:
executor._confirm_callback = (
lambda prompt: click.confirm(f"\n{prompt}", default=False)
executor._confirm_callback = lambda prompt: click.confirm(
f"\n{prompt}", default=False
)
# Run the tick with a live trace rather than blocking in silence — the
# message we just queued is consumed as this tick's input, so the user
+30
View File
@@ -276,6 +276,36 @@ def hardware() -> None:
config.add_command(show_group, "show")
@config.command("path")
def show_path() -> None:
"""Print the resolved OpenJarvis directories (home, config, cache).
All OpenJarvis state lives under a single root, resolved in priority
order: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
``~/.openjarvis``. Use this to confirm where your data is stored after
setting an override.
"""
from openjarvis.core.paths import get_cache_dir, get_config_dir, get_config_path
console = Console(stderr=True)
home = get_config_dir()
override = (
"OPENJARVIS_HOME"
if os.environ.get("OPENJARVIS_HOME")
else "XDG_DATA_HOME"
if os.environ.get("XDG_DATA_HOME")
else "default (~/.openjarvis)"
)
table = Table(show_header=True, header_style="bold")
table.add_column("Directory")
table.add_column("Path", style="cyan")
table.add_row("Home (root)", str(home))
table.add_row("Config file", str(get_config_path()))
table.add_row("Cache", str(get_cache_dir()))
console.print(table)
console.print(f"[dim]Resolved via: {override}[/dim]")
def _probe_engine_host(url: str, console: Console) -> None:
"""Probe an engine host URL and print reachability status."""
try:
+5 -3
View File
@@ -354,7 +354,9 @@ def doctor(as_json: bool) -> None:
# Background tasks section
from openjarvis.cli._bg_state import get_status
from openjarvis.core.paths import get_config_dir
scripts_dir = get_config_dir() / ".scripts"
console.print("[bold]Background tasks[/bold]")
bg = get_status()
bg_failed = False
@@ -364,8 +366,8 @@ def doctor(as_json: bool) -> None:
elif bg.rust_extension == "failed":
console.print(f" [red]✗[/red] Rust extension: failed — {bg.rust_error[:80]}")
console.print(
" retry: ~/.openjarvis/.scripts/install-rust.sh && "
"~/.openjarvis/.scripts/build-extension.sh"
f" retry: {scripts_dir}/install-rust.sh && "
f"{scripts_dir}/build-extension.sh"
)
bg_failed = True
else:
@@ -380,7 +382,7 @@ def doctor(as_json: bool) -> None:
console.print(f" [green]✓[/green] {model_id}: ready")
elif state == "failed":
console.print(f" [red]✗[/red] {model_id}: failed")
console.print(f" retry: ~/.openjarvis/.scripts/pull-model.sh {model_id}")
console.print(f" retry: {scripts_dir}/pull-model.sh {model_id}")
bg_failed = True
else:
console.print(f" [yellow]…[/yellow] {model_id}: downloading")
+2 -1
View File
@@ -7,6 +7,7 @@ from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Optional, Union
from openjarvis.core.paths import get_config_dir
from openjarvis.security.credential_stripper import CredentialStripper
_stripper = CredentialStripper()
@@ -68,7 +69,7 @@ def setup_logging(
if log_file is None:
from openjarvis.security.file_utils import secure_mkdir
log_dir = Path.home() / ".openjarvis"
log_dir = get_config_dir()
secure_mkdir(log_dir)
log_file = log_dir / "cli.log"
file_handler = RotatingFileHandler(
+3 -1
View File
@@ -11,6 +11,8 @@ from typing import Callable, List
import click
from openjarvis.core.paths import get_config_dir
# Engine ports that should only be listening on localhost.
_ENGINE_PORTS = {11434, 8080, 8000, 30000, 1234, 52415, 18181}
@@ -132,7 +134,7 @@ class PrivacyScanner:
def check_icloud_sync(self) -> ScanResult:
"""Check whether ~/.openjarvis is inside iCloud Drive sync scope."""
try:
config_path = Path("~/.openjarvis").expanduser().resolve()
config_path = get_config_dir().resolve()
icloud_path = Path("~/Library/Mobile Documents/").expanduser().resolve()
if str(config_path).startswith(str(icloud_path)):
return ScanResult(
+3 -8
View File
@@ -11,6 +11,7 @@ from rich.console import Console
from openjarvis.cli._banner import print_banner
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.engine import (
discover_engines,
discover_models,
@@ -496,13 +497,9 @@ def serve(
agent_manager = None
if config.agent_manager.enabled:
try:
from pathlib import Path
from openjarvis.agents.manager import AgentManager
am_db = config.agent_manager.db_path or str(
Path("~/.openjarvis/agents.db").expanduser()
)
am_db = config.agent_manager.db_path or str(get_config_dir() / "agents.db")
# The server owns the scheduler and is the authoritative tick
# runner — on boot it holds no locks, so it (and only it) sweeps
# any zombie running→idle left by a previous crash.
@@ -607,9 +604,7 @@ def serve(
try:
import tomllib
_cfg_path = str(
__import__("pathlib").Path.home() / ".openjarvis" / "config.toml"
)
_cfg_path = str(get_config_dir() / "config.toml")
with open(_cfg_path, "rb") as _f:
_raw = tomllib.load(_f)
api_key = _raw.get("server", {}).get("auth", {}).get("api_key", "")
+8 -5
View File
@@ -11,6 +11,7 @@ from rich.table import Table
from openjarvis.core.config import load_config
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.manager import SkillManager
@@ -28,12 +29,12 @@ def _get_trace_store():
def _get_discovered_dir() -> Path:
"""Return the directory where discovered skill manifests are written."""
return Path("~/.openjarvis/skills/discovered/").expanduser()
return get_config_dir() / "skills" / "discovered"
def _get_overlay_dir() -> Path:
"""Return the directory where optimization overlays are stored."""
return Path("~/.openjarvis/learning/skills/").expanduser()
return get_config_dir() / "learning" / "skills"
def _get_skill_paths() -> List[Path]:
@@ -41,7 +42,7 @@ def _get_skill_paths() -> List[Path]:
workspace = Path("./skills")
if workspace.exists():
paths.append(workspace)
user_dir = Path("~/.openjarvis/skills/").expanduser()
user_dir = get_config_dir() / "skills"
paths.append(user_dir)
return paths
@@ -174,8 +175,10 @@ def _get_resolver(source: str, url: str = ""):
from openjarvis.skills.sources.github import GitHubResolver
cache = _Path(
"~/.openjarvis/skill-cache/github/" + url.rstrip("/").rsplit("/", 1)[-1]
).expanduser()
str(get_config_dir() / "skill-cache" / "github")
+ "/"
+ url.rstrip("/").rsplit("/", 1)[-1]
)
return GitHubResolver(cache_root=cache, repo_url=url)
raise click.BadParameter(f"Unknown source: {source!r}")
+10 -19
View File
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -290,12 +289,18 @@ class GCalendarConnector(BaseConnector):
"""Handle the OAuth callback.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
``.apps.googleusercontent.com``), persist the client credentials only.
The browser consent + codetoken exchange is owned by the in-process
server flow (``/v1/connectors/{id}/oauth/start`` ``/oauth/callback``),
which writes the real ``access_token`` to every Google credential file.
The previous daemon-thread browser flow (its own ``localhost:8789``
callback server) failed silently in the bundled desktop context and is
intentionally removed here (issue #512).
Any other *code* is treated as a raw token / auth code.
"""
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
save_tokens(
@@ -305,20 +310,6 @@ class GCalendarConnector(BaseConnector):
"client_secret": client_secret.strip(),
},
)
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
+10 -19
View File
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -195,12 +194,18 @@ class GContactsConnector(BaseConnector):
"""Handle the OAuth callback.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
``.apps.googleusercontent.com``), persist the client credentials only.
The browser consent + codetoken exchange is owned by the in-process
server flow (``/v1/connectors/{id}/oauth/start`` ``/oauth/callback``),
which writes the real ``access_token`` to every Google credential file.
The previous daemon-thread browser flow (its own ``localhost:8789``
callback server) failed silently in the bundled desktop context and is
intentionally removed here (issue #512).
Any other *code* is treated as a raw token / auth code.
"""
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
save_tokens(
@@ -210,20 +215,6 @@ class GContactsConnector(BaseConnector):
"client_secret": client_secret.strip(),
},
)
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
+15 -21
View File
@@ -20,7 +20,6 @@ from openjarvis.connectors.oauth import (
delete_tokens,
load_tokens,
resolve_google_credentials,
run_oauth_flow,
save_tokens,
)
from openjarvis.core.config import DEFAULT_CONFIG_DIR
@@ -178,15 +177,25 @@ class GDriveConnector(BaseConnector):
"""Handle the OAuth callback.
If *code* looks like a ``client_id:client_secret`` pair (containing
``.apps.googleusercontent.com``), store the credentials and trigger
the full browser-based OAuth flow. Otherwise treat it as a raw
token / auth code.
``.apps.googleusercontent.com``), persist the client credentials only.
The actual browser consent + codetoken exchange is owned by the
in-process server flow (``/v1/connectors/{id}/oauth/start``
``/oauth/callback``), which writes the real ``access_token`` to every
Google credential file.
Previously this spawned a daemon thread that popped a browser and ran
its own ``localhost:8789`` callback server; that thread failed silently
in the bundled desktop context, so the connector never gained an access
token and never appeared in Data Sources (issue #512). The background
flow is intentionally removed here.
Any other *code* is treated as a raw token / auth code.
"""
code = code.strip()
# If user pastes client_id:client_secret, store and run OAuth flow
# A pasted client_id:client_secret pair is the app registration, not a
# completed credential — persist it and let the server flow finish auth.
if ":" in code and ".apps.googleusercontent.com" in code:
client_id, client_secret = code.split(":", 1)
# Save credentials immediately
save_tokens(
self._credentials_path,
{
@@ -194,21 +203,6 @@ class GDriveConnector(BaseConnector):
"client_secret": client_secret.strip(),
},
)
# Run OAuth flow in background thread to avoid blocking
import threading
def _run() -> None:
try:
run_oauth_flow(
client_id=client_id.strip(),
client_secret=client_secret.strip(),
scopes=GOOGLE_ALL_SCOPES,
credentials_path=self._credentials_path,
)
except Exception: # noqa: BLE001
pass
threading.Thread(target=_run, daemon=True).start()
else:
# Raw token or auth code
save_tokens(self._credentials_path, {"token": code})
+55 -21
View File
@@ -16,6 +16,14 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from openjarvis.core.paths import (
ConfigurationError,
get_cache_dir,
get_config_dir,
get_config_path,
get_data_dir,
)
if TYPE_CHECKING:
# Only used by type-checkers (mypy/pyright) for the ``JarvisConfig.mining``
# field annotation. The runtime import is deferred inside
@@ -33,15 +41,24 @@ except ModuleNotFoundError:
# Hardware dataclasses
# ---------------------------------------------------------------------------
DEFAULT_CONFIG_DIR = Path.home() / ".openjarvis"
DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
# Legacy names, kept for the ~45 modules that import them. They are resolved
# once at import via the env-aware resolver in ``openjarvis.core.paths`` (the
# install-script model: ``OPENJARVIS_HOME`` / ``XDG_DATA_HOME`` are set before
# the process starts). They are real module attributes — not computed lazily —
# so existing tests can ``monkeypatch.setattr`` them and so dataclass-instance
# defaults stay consistent. Code that must react to a mid-process env change
# (or wants the override regardless of import order) should call
# ``get_config_dir()`` / ``get_config_path()`` directly; the dataclass field
# defaults below already do this via ``default_factory``.
DEFAULT_CONFIG_DIR = get_config_dir()
DEFAULT_CONFIG_PATH = get_config_path()
def _ensure_config_dir() -> Path:
"""Ensure the config directory exists with restrictive permissions."""
from openjarvis.security.file_utils import secure_mkdir
return secure_mkdir(DEFAULT_CONFIG_DIR)
return secure_mkdir(get_config_dir())
@dataclass(slots=True)
@@ -742,7 +759,9 @@ class SkillsLearningConfig:
optimizer: str = "dspy" # "dspy" or "gepa"
min_traces_per_skill: int = 20
optimization_interval_seconds: int = 86400
overlay_dir: str = "~/.openjarvis/learning/skills/"
overlay_dir: str = field(
default_factory=lambda: str(get_config_dir() / "learning" / "skills")
)
@dataclass(slots=True)
@@ -894,7 +913,7 @@ class StorageConfig:
"""Storage (memory) backend settings."""
default_backend: str = "sqlite"
db_path: str = str(DEFAULT_CONFIG_DIR / "memory.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "memory.db"))
context_top_k: int = 5
context_min_score: float = 0.0
context_max_tokens: int = 2048
@@ -998,7 +1017,7 @@ class TelemetryConfig:
"""Telemetry persistence settings."""
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "telemetry.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "telemetry.db"))
gpu_metrics: bool = False
gpu_poll_interval_ms: int = 50
energy_vendor: str = "" # auto-detect or force "nvidia"/"amd"/"apple"/"cpu_rapl"
@@ -1023,7 +1042,7 @@ class AnalyticsConfig:
enabled: bool = True
host: str = "https://34.231.106.201.sslip.io"
key: str = "phc_ysKu72QaxzYNmDpHFcesD2ZZAe68zkdWJEKoYYkc5e3n"
anon_id_path: str = str(DEFAULT_CONFIG_DIR / "anon_id")
anon_id_path: str = field(default_factory=lambda: str(get_config_dir() / "anon_id"))
flush_interval_seconds: int = 30
flush_at_size: int = 100
@@ -1033,7 +1052,7 @@ class TracesConfig:
"""Trace system settings."""
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "traces.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "traces.db"))
@dataclass(slots=True)
@@ -1235,7 +1254,9 @@ class SecurityConfig:
mode: str = "redact" # "redact" | "warn" | "block"
secret_scanner: bool = True
pii_scanner: bool = True
audit_log_path: str = str(DEFAULT_CONFIG_DIR / "audit.db")
audit_log_path: str = field(
default_factory=lambda: str(get_config_dir() / "audit.db")
)
enforce_tool_confirmation: bool = True
merkle_audit: bool = True
signing_key_path: str = ""
@@ -1246,7 +1267,9 @@ class SecurityConfig:
local_engine_bypass: bool = False
local_tool_bypass: bool = False
profile: str = ""
vault_key_path: str = str(DEFAULT_CONFIG_DIR / ".vault_key")
vault_key_path: str = field(
default_factory=lambda: str(get_config_dir() / ".vault_key")
)
capabilities: CapabilitiesConfig = field(default_factory=CapabilitiesConfig)
@@ -1367,7 +1390,7 @@ class SessionConfig:
enabled: bool = False
max_age_hours: float = 24.0
consolidation_threshold: int = 100
db_path: str = str(DEFAULT_CONFIG_DIR / "sessions.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "sessions.db"))
@dataclass(slots=True)
@@ -1385,7 +1408,9 @@ class OperatorsConfig:
"""Operator lifecycle settings."""
enabled: bool = False
manifests_dir: str = "~/.openjarvis/operators"
manifests_dir: str = field(
default_factory=lambda: str(get_config_dir() / "operators")
)
auto_activate: str = "" # Comma-separated operator IDs
@@ -1411,7 +1436,7 @@ class OptimizeConfig:
benchmark: str = ""
max_samples: int = 50
judge_model: str = "gpt-5-mini-2025-08-07"
db_path: str = str(DEFAULT_CONFIG_DIR / "optimize.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "optimize.db"))
@dataclass(slots=True)
@@ -1419,18 +1444,20 @@ class AgentManagerConfig:
"""Persistent agent manager settings."""
enabled: bool = True
db_path: str = str(DEFAULT_CONFIG_DIR / "agents.db")
db_path: str = field(default_factory=lambda: str(get_config_dir() / "agents.db"))
@dataclass(slots=True)
class MemoryFilesConfig:
"""Persistent memory-file paths and nudge settings."""
soul_path: str = "~/.openjarvis/SOUL.md"
memory_path: str = "~/.openjarvis/MEMORY.md"
user_path: str = "~/.openjarvis/USER.md"
soul_path: str = field(default_factory=lambda: str(get_config_dir() / "SOUL.md"))
memory_path: str = field(
default_factory=lambda: str(get_config_dir() / "MEMORY.md")
)
user_path: str = field(default_factory=lambda: str(get_config_dir() / "USER.md"))
nudge_interval: int = 10
persona_name: str = "" # named persona dir under ~/.openjarvis/personas/<name>/
persona_name: str = "" # named persona dir under <config-dir>/personas/<name>/
@dataclass(slots=True)
@@ -1469,13 +1496,15 @@ class SkillsConfig:
"""Configuration for agent-authored procedural skills."""
enabled: bool = True
skills_dir: str = "~/.openjarvis/skills/"
skills_dir: str = field(default_factory=lambda: str(get_config_dir() / "skills"))
active: str = "*"
auto_discover: bool = True
auto_sync: bool = False
nudge_interval: int = 15
index_repo: str = "https://github.com/openjarvis/skill-index.git"
index_dir: str = "~/.openjarvis/skill-index/"
index_dir: str = field(
default_factory=lambda: str(get_config_dir() / "skill-index")
)
max_depth: int = 5
sandbox_dangerous: bool = True
sources: List[SkillSourceConfig] = field(default_factory=list)
@@ -1781,7 +1810,7 @@ def load_config(path: Optional[Path] = None) -> JarvisConfig:
elif os.environ.get("OPENJARVIS_CONFIG"):
config_path = Path(os.environ["OPENJARVIS_CONFIG"]).expanduser().resolve()
else:
config_path = DEFAULT_CONFIG_PATH
config_path = get_config_path()
if config_path.exists():
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
@@ -2119,9 +2148,14 @@ __all__ = [
"BrowserConfig",
"CapabilitiesConfig",
"ChannelConfig",
"ConfigurationError",
"DEFAULT_CONFIG_DIR",
"DEFAULT_CONFIG_PATH",
"DiscordChannelConfig",
"get_cache_dir",
"get_config_dir",
"get_config_path",
"get_data_dir",
"EmailChannelConfig",
"EngineConfig",
"FeishuChannelConfig",
+10 -3
View File
@@ -10,13 +10,20 @@ import os
import threading
from pathlib import Path
from openjarvis.core.paths import get_config_dir
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
_LOCK = threading.Lock()
_DEFAULT_PATH = Path.home() / ".openjarvis" / "credentials.toml"
def _default_path() -> Path:
"""Resolve the credentials file under the OpenJarvis root (env-aware)."""
return get_config_dir() / "credentials.toml"
TOOL_CREDENTIALS: dict[str, list[str]] = {
"web_search": ["TAVILY_API_KEY"],
@@ -53,7 +60,7 @@ TOOL_CREDENTIALS: dict[str, list[str]] = {
def load_credentials(path: Path | None = None) -> dict[str, dict[str, str]]:
"""Load credentials from TOML file."""
p = Path(path) if path else _DEFAULT_PATH
p = Path(path) if path else _default_path()
if not p.exists():
return {}
with open(p, "rb") as f:
@@ -75,7 +82,7 @@ def save_credential(
if not stripped:
raise ValueError("Credential value must not be empty")
p = Path(path) if path else _DEFAULT_PATH
p = Path(path) if path else _default_path()
with _LOCK:
creds = load_credentials(path=p)
if tool_name not in creds:
+122
View File
@@ -0,0 +1,122 @@
"""Central, env-aware resolution of OpenJarvis' home directory.
OpenJarvis keeps all of its runtime state (config, databases, caches, logs,
credentials, skills, recipes, ) under a single root so it never clutters the
user's home directory beyond one directory. That root is resolved here, with
the following precedence (highest first):
1. ``$OPENJARVIS_HOME`` explicit override (also honored by the shell
installer, see ``scripts/install/install.sh``).
2. ``$XDG_DATA_HOME/openjarvis`` when ``$XDG_DATA_HOME`` is set, follow the
XDG Base Directory spec by nesting a single ``openjarvis`` directory under
it. We deliberately use ONE directory rather than splitting across XDG
config/data/cache so the install tree stays self-contained and relocatable.
3. ``~/.openjarvis`` the historical default. With no env vars set, the
resolved path is exactly this, so existing installs are untouched.
``config.py`` re-exports :func:`get_config_dir` results through the legacy
``DEFAULT_CONFIG_DIR``/``DEFAULT_CONFIG_PATH`` names (computed dynamically) so
the ~45 modules that import those names keep working while honoring the
override. Modules that previously hardcoded ``Path.home() / ".openjarvis"``
should call :func:`get_config_dir` (or :func:`get_data_dir` /
:func:`get_cache_dir`) instead.
Defense in depth: the resolved root must never live inside the OpenJarvis
source tree (a misconfigured ``$OPENJARVIS_HOME`` pointing at the repo would
otherwise scatter runtime artifacts into the working tree). This mirrors the
guard in ``learning/spec_search/storage/paths.py`` and fails loudly per
REVIEW.md's no-silent-failure discipline.
"""
from __future__ import annotations
import os
from pathlib import Path
_DEFAULT_DIR_NAME = ".openjarvis"
_XDG_SUBDIR_NAME = "openjarvis"
class ConfigurationError(RuntimeError):
"""Raised when the resolved home directory would violate isolation guarantees."""
def _find_source_root() -> Path | None:
"""Walk upward from this module to find the OpenJarvis source root.
Returns the directory containing the OpenJarvis ``pyproject.toml`` (the one
whose ``name = "openjarvis"``), or ``None`` when running from an installed
wheel rather than a source checkout.
"""
here = Path(__file__).resolve()
for candidate in (here, *here.parents):
py = candidate / "pyproject.toml"
if py.exists():
try:
content = py.read_text(encoding="utf-8")
except OSError:
continue
if 'name = "openjarvis"' in content.lower():
return candidate
return None
def _reject_source_tree(path: Path) -> Path:
"""Raise if ``path`` resolves inside the OpenJarvis source tree."""
source_root = _find_source_root()
if source_root is not None:
try:
path.relative_to(source_root)
except ValueError:
pass # Good — not inside the source tree.
else:
raise ConfigurationError(
f"OpenJarvis home ({path}) is inside the source tree "
f"({source_root}). OpenJarvis refuses to write runtime state "
"inside its own repo. Set OPENJARVIS_HOME (or XDG_DATA_HOME) "
"to a directory outside the repo (default: ~/.openjarvis)."
)
return path
def get_config_dir() -> Path:
"""Resolve OpenJarvis' single root directory, honoring env overrides.
Precedence: ``$OPENJARVIS_HOME`` > ``$XDG_DATA_HOME/openjarvis`` >
``~/.openjarvis``. The result is always absolute and is rejected if it
falls inside the OpenJarvis source tree.
"""
env_home = os.environ.get("OPENJARVIS_HOME")
if env_home:
resolved = Path(env_home).expanduser().resolve()
return _reject_source_tree(resolved)
xdg_data = os.environ.get("XDG_DATA_HOME")
if xdg_data:
resolved = (Path(xdg_data).expanduser() / _XDG_SUBDIR_NAME).resolve()
return _reject_source_tree(resolved)
return (Path.home() / _DEFAULT_DIR_NAME).resolve()
def get_config_path() -> Path:
"""Resolve the path to ``config.toml`` under the OpenJarvis root."""
return get_config_dir() / "config.toml"
def get_data_dir() -> Path:
"""Resolve the directory for persistent data (databases, blobs, …).
Consolidated under the single root; identical to :func:`get_config_dir`.
Provided as a distinct name so call sites read intentionally.
"""
return get_config_dir()
def get_cache_dir() -> Path:
"""Resolve the directory for regenerable caches (eval datasets, etc.).
Lives at ``<root>/cache`` so caches stay inside the single OpenJarvis
directory instead of scattering across ``~/.cache``.
"""
return get_config_dir() / "cache"
+2 -1
View File
@@ -11,11 +11,12 @@ import shutil
from pathlib import Path
from typing import Iterable, List, MutableMapping, Optional, Sequence
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
_DEFAULT_CACHE_DIR = Path.home() / ".cache" / "gaia_benchmark"
_DEFAULT_CACHE_DIR = get_cache_dir() / "gaia_benchmark"
_DEFAULT_INPUT_PROMPT = """Please answer the question below. You should:
@@ -17,6 +17,7 @@ import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
@@ -24,7 +25,7 @@ from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
LIVERESEARCH_REPO = "https://github.com/Ayanami0730/deep_research_bench.git"
CACHE_DIR = Path.home() / ".cache" / "liveresearch_bench"
CACHE_DIR = get_cache_dir() / "liveresearch_bench"
def _load_jsonl(path: Path) -> List[Dict[str, Any]]:
+2 -3
View File
@@ -13,6 +13,7 @@ import re
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
@@ -64,9 +65,7 @@ class LogHubDataset(DatasetProvider):
f"Choose from: {list(_DATASETS.keys())}"
)
self._subset = subset
self._cache_dir = (
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "loghub"
)
self._cache_dir = Path(cache_dir) if cache_dir else get_cache_dir() / "loghub"
self._records: List[EvalRecord] = []
def load(
+2 -1
View File
@@ -15,6 +15,7 @@ import random
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
@@ -41,7 +42,7 @@ class PaperArenaDataset(DatasetProvider):
cache_dir: Optional[str] = None,
) -> None:
self._cache_dir = (
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "paperarena"
Path(cache_dir) if cache_dir else get_cache_dir() / "paperarena"
)
self._records: List[EvalRecord] = []
+2 -1
View File
@@ -18,6 +18,7 @@ from typing import Any, Dict, Iterable, List, Optional
import yaml
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
@@ -25,7 +26,7 @@ from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
PINCHBENCH_REPO = "https://github.com/pinchbench/skill.git"
CACHE_DIR = Path.home() / ".cache" / "pinchbench"
CACHE_DIR = get_cache_dir() / "pinchbench"
def _parse_task_markdown(content: str, filename: str = "") -> Dict[str, Any]:
+2 -2
View File
@@ -12,9 +12,9 @@ import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.splits import apply_split
from openjarvis.evals.core.types import EvalRecord
@@ -22,7 +22,7 @@ from openjarvis.evals.core.types import EvalRecord
LOGGER = logging.getLogger(__name__)
TAU2_REPO = "https://github.com/sierra-research/tau2-bench.git"
CACHE_DIR = Path.home() / ".cache" / "tau2-bench"
CACHE_DIR = get_cache_dir() / "tau2-bench"
DOMAINS = ("airline", "retail", "telecom")
@@ -21,6 +21,7 @@ import subprocess
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.dataset import DatasetProvider
from openjarvis.evals.core.types import EvalRecord
@@ -56,7 +57,7 @@ class WebChoreArenaDataset(DatasetProvider):
) -> None:
self._subset = subset # "all", "small", or a site name
self._cache_dir = (
Path(cache_dir) if cache_dir else Path.home() / ".cache" / "webchorearena"
Path(cache_dir) if cache_dir else get_cache_dir() / "webchorearena"
)
self._headless = headless
self._records: List[EvalRecord] = []
@@ -49,6 +49,7 @@ import tempfile
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from openjarvis.core.paths import get_cache_dir
from openjarvis.evals.core.scorer import Scorer
from openjarvis.evals.core.types import EvalRecord
@@ -93,9 +94,7 @@ def _run_subprocess_hard_timeout(
)
try:
stdout, stderr = proc.communicate(timeout=timeout_s)
return subprocess.CompletedProcess(
cmd, proc.returncode, stdout, stderr
)
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
except subprocess.TimeoutExpired:
# Kill the whole group, not just the direct child — Modal harness
# subprocesses fork workers that would otherwise keep pipes open.
@@ -115,9 +114,7 @@ def _run_subprocess_hard_timeout(
stdout, stderr = proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
stdout, stderr = "", ""
raise subprocess.TimeoutExpired(
cmd, timeout_s, output=stdout, stderr=stderr
)
raise subprocess.TimeoutExpired(cmd, timeout_s, output=stdout, stderr=stderr)
# ---------- Patch tracking ----------
@@ -208,11 +205,11 @@ def _patch_modal_sandbox_source() -> None:
# Upstream changed the line — bail rather than apply blindly.
return
replacement = (
' # ' + _CGROUP_SOURCE_SENTINEL + '\n'
' try:\n'
" # " + _CGROUP_SOURCE_SENTINEL + "\n"
" try:\n"
' self.write_file("/sys/fs/cgroup/cpu/cpu.shares", "2048")\n'
' except FileNotFoundError:\n'
' pass # cgroup v2 Modal sandbox — path missing is fine\n'
" except FileNotFoundError:\n"
" pass # cgroup v2 Modal sandbox — path missing is fine\n"
)
new_src = src.replace(needle + "\n", replacement, 1)
try:
@@ -324,22 +321,23 @@ def extract_patch(text: str) -> Optional[str]:
# ---------- Harness invocation ----------
def _harness_cache_dir() -> Path:
"""Where the swebench subprocess writes its report JSON + logs/ tree.
Defaults to ``$OPENJARVIS_HOME/.swebench-cache`` if set, otherwise to a
process-shared tempdir. Pin both so we don't pollute the project root.
Consolidated under the env-aware OpenJarvis cache root
(``<openjarvis-home>/cache/swebench``) so it never pollutes the project
root or scatters across ``$HOME``. Honors ``OPENJARVIS_HOME`` /
``XDG_DATA_HOME`` via :func:`openjarvis.core.paths.get_cache_dir`.
"""
home = os.environ.get("OPENJARVIS_HOME")
if home:
cache = Path(home) / ".swebench-cache"
else:
cache = Path(tempfile.gettempdir()) / "openjarvis-swebench-cache"
cache = get_cache_dir() / "swebench"
cache.mkdir(parents=True, exist_ok=True)
return cache
def _find_report(cache: Path, instance_id: str, run_id: str) -> Optional[Dict[str, Any]]:
def _find_report(
cache: Path, instance_id: str, run_id: str
) -> Optional[Dict[str, Any]]:
"""Find the harness's report JSON for one instance.
swebench writes ``<model_name_or_path>.<run_id>.json`` inside the
@@ -427,26 +425,40 @@ def _run_harness(
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
preds_path = tmp_path / "predictions.jsonl"
preds_path.write_text(json.dumps({
"instance_id": instance_id,
"model_name_or_path": "openjarvis-harness",
"model_patch": patch,
}) + "\n")
preds_path.write_text(
json.dumps(
{
"instance_id": instance_id,
"model_name_or_path": "openjarvis-harness",
"model_patch": patch,
}
)
+ "\n"
)
cmd = [
sys.executable, "-m", "swebench.harness.run_evaluation",
"--predictions_path", str(preds_path),
"--max_workers", "1",
"--run_id", run_id,
"--dataset_name", "SWE-bench/SWE-bench_Verified",
"--instance_ids", instance_id,
sys.executable,
"-m",
"swebench.harness.run_evaluation",
"--predictions_path",
str(preds_path),
"--max_workers",
"1",
"--run_id",
run_id,
"--dataset_name",
"SWE-bench/SWE-bench_Verified",
"--instance_ids",
instance_id,
]
if backend == "modal":
cmd += ["--modal", "true"]
try:
proc = _run_subprocess_hard_timeout(
cmd, timeout_s=timeout_s, cwd=str(cache),
cmd,
timeout_s=timeout_s,
cwd=str(cache),
)
except subprocess.TimeoutExpired as exc:
# The harness subprocess (and its Modal grandchildren) exceeded
@@ -497,6 +509,7 @@ def _run_harness(
# ---------- Scorer ----------
class SWEBenchHarnessScorer(Scorer):
"""SWE-bench Verified scorer that runs the official harness.
@@ -516,7 +529,7 @@ class SWEBenchHarnessScorer(Scorer):
timeout_s: int = 1800,
cell_name: Optional[str] = None,
judge_backend: object = None, # noqa: ARG002 — CLI factory compat
judge_model: str = "", # noqa: ARG002 — CLI factory compat
judge_model: str = "", # noqa: ARG002 — CLI factory compat
) -> None:
self._timeout_s = int(timeout_s)
# ``cell_name`` namespaces the ``run_id`` so concurrent cells scoring
@@ -538,16 +551,15 @@ class SWEBenchHarnessScorer(Scorer):
if patch is None:
return False, {"reason": "no_patch_extracted"}
instance_id = (
record.metadata.get("instance_id")
or record.record_id
or ""
)
instance_id = record.metadata.get("instance_id") or record.record_id or ""
if not instance_id:
return False, {"reason": "missing_instance_id"}
result = _run_harness(
instance_id, patch, self._timeout_s, cell_name=self._cell_name,
instance_id,
patch,
self._timeout_s,
cell_name=self._cell_name,
)
details = dict(result.get("details", {}))
details["patch"] = patch
+5 -5
View File
@@ -16,6 +16,8 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.paths import get_config_dir
LOGGER = logging.getLogger(__name__)
CONDITIONS = (
@@ -52,14 +54,12 @@ class SkillBenchmarkConfig:
seeds: List[int] = field(default_factory=lambda: [42, 43, 44])
max_samples: Optional[int] = None
output_dir: Path = field(default_factory=lambda: Path("docs/superpowers/results/"))
skills_dir: Path = field(
default_factory=lambda: Path("~/.openjarvis/skills/").expanduser()
)
skills_dir: Path = field(default_factory=lambda: get_config_dir() / "skills")
overlay_dir_dspy: Path = field(
default_factory=lambda: Path("~/.openjarvis/learning/skills-dspy/").expanduser()
default_factory=lambda: get_config_dir() / "learning" / "skills-dspy"
)
overlay_dir_gepa: Path = field(
default_factory=lambda: Path("~/.openjarvis/learning/skills-gepa/").expanduser()
default_factory=lambda: get_config_dir() / "learning" / "skills-gepa"
)
@@ -30,6 +30,7 @@ from pathlib import Path
from typing import Any, Dict, List
from openjarvis.core.config import ACEOptimizerConfig
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import LearningRegistry
from openjarvis.learning._stubs import AgentLearningPolicy
@@ -48,7 +49,7 @@ except ImportError:
def _default_save_dir(task_name: str) -> Path:
return Path.home() / ".openjarvis" / "learning" / "ace" / task_name
return get_config_dir() / "learning" / "ace" / task_name
class _TraceDataProcessor:
@@ -87,9 +88,7 @@ class _TraceDataProcessor:
if not predictions:
return 0.0
n_correct = sum(
1
for p, g in zip(predictions, ground_truths)
if cls.answer_is_correct(p, g)
1 for p, g in zip(predictions, ground_truths) if cls.answer_is_correct(p, g)
)
return n_correct / len(predictions)
@@ -149,8 +148,7 @@ class ACEAgentOptimizer:
return {
"status": "skipped",
"reason": (
f"only {len(traces)} traces, "
f"min_traces={self.config.min_traces}"
f"only {len(traces)} traces, min_traces={self.config.min_traces}"
),
}
@@ -158,8 +156,7 @@ class ACEAgentOptimizer:
return {
"status": "error",
"reason": (
"ace not installed (pip install "
"'openjarvis[learning-ace]')"
"ace not installed (pip install 'openjarvis[learning-ace]')"
),
}
@@ -19,6 +19,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Trace, TraceStep
from openjarvis.skills.manager import SkillManager
from openjarvis.skills.overlay import SkillOverlay, write_overlay
@@ -99,7 +100,7 @@ class SkillOptimizer:
pass
if overlay_dir is None:
overlay_dir = Path(
"~/.openjarvis/learning/skills/"
str(get_config_dir() / "learning" / "skills")
).expanduser()
overlay_dir = Path(overlay_dir).expanduser()
@@ -11,14 +11,21 @@ writing artifacts into the working tree.
from __future__ import annotations
import os
from pathlib import Path
from openjarvis.core.paths import ConfigurationError, get_config_dir
from openjarvis.security.file_utils import secure_mkdir
class ConfigurationError(RuntimeError):
"""Raised when path configuration would violate isolation guarantees."""
# ``ConfigurationError`` is re-exported from ``openjarvis.core.paths`` (it used
# to be defined here). Spec search now resolves the home dir through the unified
# core resolver, which raises the same exception type on a source-tree path, so
# we alias rather than redefine to keep ``except ConfigurationError`` callers and
# existing tests working.
__all__ = [
"ConfigurationError",
"ensure_spec_search_dirs",
"resolve_spec_search_root",
]
def _find_source_root() -> Path | None:
@@ -42,11 +49,12 @@ def _find_source_root() -> Path | None:
def _resolve_openjarvis_home() -> Path:
"""Resolve the OPENJARVIS_HOME directory (env var or default)."""
env = os.environ.get("OPENJARVIS_HOME")
if env:
return Path(env).expanduser().resolve()
return (Path.home() / ".openjarvis").resolve()
"""Resolve the OpenJarvis home directory via the unified core resolver.
Delegates to ``get_config_dir`` so spec-search honors the same env-aware
resolution (OPENJARVIS_HOME and XDG) as the rest of the framework.
"""
return get_config_dir()
def resolve_spec_search_root() -> Path:
+3 -3
View File
@@ -9,7 +9,7 @@ section 7.3 for the rev-bump workflow.
from __future__ import annotations
from pathlib import Path
from openjarvis.core.paths import get_config_dir
PEARL_REPO = "https://github.com/pearl-research-labs/pearl.git"
# TODO at implementation time: replace with the specific commit/tag verified
@@ -47,9 +47,9 @@ DEFAULT_PEARLD_RPC_URL = "http://localhost:44107"
MIN_FREE_DISK_GB = 200
# Runtime sidecar location (single-session assumption — see spec §8.8).
RUNTIME_DIR = Path.home() / ".openjarvis" / "runtime"
RUNTIME_DIR = get_config_dir() / "runtime"
SIDECAR_PATH = RUNTIME_DIR / "mining.json"
SIDECAR_LOCK_PATH = RUNTIME_DIR / "mining.lock"
# Pearl source cache for build-from-pin path (see spec §7.2).
PEARL_CACHE_DIR = Path.home() / ".openjarvis" / "cache" / "pearl"
PEARL_CACHE_DIR = get_config_dir() / "cache" / "pearl"
+2 -1
View File
@@ -17,6 +17,7 @@ import urllib.request
from pathlib import Path
from openjarvis.core.config import HardwareInfo
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import MinerRegistry
from . import _install
@@ -47,7 +48,7 @@ def _sidecar_path() -> Path:
def _log_dir() -> Path:
"""Return the logs directory. Override in tests."""
return Path.home() / ".openjarvis" / "logs" / "mining"
return get_config_dir() / "logs" / "mining"
def _parse_gateway_metrics(text: str, *, provider_id: str) -> MiningStats:
+2 -1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from typing import List, Literal, Optional, Tuple
from openjarvis.core.config import MemoryFilesConfig, SystemPromptConfig
from openjarvis.core.paths import get_config_dir
PromptCacheSegment = Literal["frozen_prefix", "dynamic_suffix"]
@@ -279,7 +280,7 @@ class SystemPromptBuilder:
f"Invalid persona name {name!r}: must be a simple "
"identifier (no path separators or '..')."
)
base = Path.home() / ".openjarvis" / "personas" / name
base = get_config_dir() / "personas" / name
return MemoryFilesConfig(
soul_path=str(base / "SOUL.md"),
memory_path=str(base / "MEMORY.md"),
+3 -2
View File
@@ -19,13 +19,14 @@ try:
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
from openjarvis.core.paths import get_config_dir
# Built-in recipes directory (package data)
_PROJECT_RECIPES_DIR = Path(__file__).resolve().parent / "data"
_PROJECT_OPERATORS_DIR = _PROJECT_RECIPES_DIR / "operators"
# User-level directories
_USER_RECIPES_DIR = Path.home() / ".openjarvis" / "recipes"
_USER_OPERATORS_DIR = Path.home() / ".openjarvis" / "operators"
_USER_RECIPES_DIR = get_config_dir() / "recipes"
_USER_OPERATORS_DIR = get_config_dir() / "operators"
@dataclass(slots=True)
@@ -2095,10 +2095,13 @@ def create_agent_manager_router(
raise HTTPException(status_code=404, detail="Agent not found")
try:
from openjarvis.core.config import load_config
from openjarvis.core.paths import get_config_dir
from openjarvis.traces.store import TraceStore
config = load_config()
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
store = TraceStore(
config.traces.db_path or str(get_config_dir() / "traces.db")
)
traces = store.list_traces(agent=agent_id, limit=limit)
return {
"traces": [
@@ -2120,10 +2123,13 @@ def create_agent_manager_router(
def get_trace(agent_id: str, trace_id: str):
try:
from openjarvis.core.config import load_config
from openjarvis.core.paths import get_config_dir
from openjarvis.traces.store import TraceStore
config = load_config()
store = TraceStore(config.traces.db_path or "~/.openjarvis/traces.db")
store = TraceStore(
config.traces.db_path or str(get_config_dir() / "traces.db")
)
trace = store.get(trace_id)
if trace is None:
raise HTTPException(status_code=404, detail="Trace not found")
+2 -2
View File
@@ -10,18 +10,18 @@ from __future__ import annotations
import json
import os
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, Sequence
import httpx
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Message
# ---------------------------------------------------------------------------
# Key / provider detection
# ---------------------------------------------------------------------------
_CLOUD_ENV_FILE = Path.home() / ".openjarvis" / "cloud-keys.env"
_CLOUD_ENV_FILE = get_config_dir() / "cloud-keys.env"
_OPENAI_PREFIXES = ("gpt-", "o1-", "o3-", "o4-", "chatgpt-")
_ANTHROPIC_PREFIXES = ("claude-",)
+95 -3
View File
@@ -3,7 +3,23 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from typing import TYPE_CHECKING, Any, Dict, Optional
# ``Request`` must be importable at *module* scope so that FastAPI can resolve
# the stringized ``request: Request`` annotations on the OAuth endpoints below.
# Because this module uses ``from __future__ import annotations``, every
# annotation is a string that FastAPI evaluates against the module globals; a
# ``Request`` imported only inside ``create_connectors_router()`` is invisible
# there, which makes FastAPI mistake ``request`` for a required *query* param
# (HTTP 422 on /oauth/start) or inject ``None`` (AttributeError on
# /oauth/callback). Keep this import at top level. See issue #512.
if TYPE_CHECKING:
from starlette.requests import Request
else:
try:
from starlette.requests import Request
except ImportError: # starlette ships with fastapi; absent only without it
Request = Any # type: ignore[assignment,misc]
logger = logging.getLogger(__name__)
@@ -78,7 +94,7 @@ def create_connectors_router():
this package.
"""
try:
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, HTTPException
except ImportError as exc:
raise ImportError(
"fastapi and pydantic are required for the connectors router"
@@ -125,6 +141,69 @@ def create_connectors_router():
"chunks": chunks,
}
def _maybe_oauth_client_pair(
connector_id: str, req: ConnectRequest
) -> Optional[Dict[str, Any]]:
"""Handle a pasted ``client_id:client_secret`` for an OAuth connector.
Returns an ``oauth_required`` directive (and persists the client
credentials to every credential file for the provider) when *req*
carries a Google ``client_id:client_secret`` pair, so the caller can
return early instead of triggering the silent background OAuth flow.
Returns ``None`` when there is no such pair (the caller then falls
through to the normal ``handle_callback`` / token path).
Raises ``HTTPException(400)`` when the pair is present but malformed or
the connector has no OAuth provider per the silent-failure discipline
in REVIEW.md, a bad credential surfaces an actionable error rather than
a perpetual ``pending`` state.
"""
from openjarvis.connectors.oauth import (
get_provider_for_connector,
save_client_credentials,
)
raw = (req.code or req.token or "").strip()
# Only the client-registration pair routes through the server flow.
# A raw access token (no ".apps.googleusercontent.com") is handled by
# the connector's handle_callback unchanged.
if ".apps.googleusercontent.com" not in raw or ":" not in raw:
return None
provider = get_provider_for_connector(connector_id)
if provider is None:
raise HTTPException(
status_code=400,
detail=f"No OAuth provider configured for '{connector_id}'",
)
client_id, client_secret = raw.split(":", 1)
client_id = client_id.strip()
client_secret = client_secret.strip()
if not client_id or not client_secret:
raise HTTPException(
status_code=400,
detail=(
"Malformed credentials — expected 'CLIENT_ID:CLIENT_SECRET'. "
f"Create an OAuth client at: {provider.setup_url}"
),
)
save_client_credentials(provider, client_id, client_secret)
# Cached instances may have resolved a stale credentials path before
# these client creds existed; drop them so /oauth/callback rebuilds
# them against the freshly written files.
for cid in provider.connector_ids:
_instances.pop(cid, None)
return {
"connector_id": connector_id,
"connected": False,
"status": "oauth_required",
"oauth_start": f"/v1/connectors/{connector_id}/oauth/start",
"sync_status": None,
}
# ------------------------------------------------------------------
# Background-sync state tracking. Defined here (before the endpoints)
# so that POST /connect can fire-and-forget into the same machinery
@@ -317,6 +396,19 @@ def create_connectors_router():
instance._connected = Path(req.path).is_dir()
elif auth_type == "oauth":
# A pasted ``client_id:client_secret`` pair is NOT a completed
# OAuth credential — it is the app registration. Persist it and
# hand the UI a directive to run the in-process browser consent
# flow (/oauth/start → /oauth/callback), which is the only path
# that actually exchanges a code for an access_token. Previously
# this routed into the connector's handle_callback, which spawned
# a daemon thread that popped a browser + ran its own
# localhost:8789 callback server; that thread fails silently in
# the bundled desktop context, so the connector never became
# connected and never appeared in Data Sources (issue #512).
directive = _maybe_oauth_client_pair(connector_id, req)
if directive is not None:
return directive
if req.code:
instance.handle_callback(req.code)
elif req.token:
@@ -433,9 +525,9 @@ def create_connectors_router():
@router.get("/{connector_id}/oauth/callback")
async def oauth_callback(
connector_id: str,
request: Request,
code: str = "",
error: str = "",
request: Request = None,
):
"""Handle OAuth callback from the provider."""
from fastapi.responses import HTMLResponse
+2 -2
View File
@@ -9,6 +9,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from openjarvis.core.paths import get_config_dir
from openjarvis.core.types import Message, Role
from openjarvis.server.models import (
ChatCompletionChunk,
@@ -821,10 +822,9 @@ async def reload_cloud_engine(request: Request):
key so that cloud models become available without a full app restart.
"""
import os
from pathlib import Path
# Re-read ~/.openjarvis/cloud-keys.env and update the running process env.
keys_path = Path.home() / ".openjarvis" / "cloud-keys.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()
+3 -1
View File
@@ -8,6 +8,8 @@ import sqlite3
from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
_MAX_HISTORY_TURNS = 20
@@ -22,7 +24,7 @@ class SessionStore:
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(Path.home() / ".openjarvis" / "sessions.db")
db_path = str(get_config_dir() / "sessions.db")
from openjarvis.security.file_utils import secure_create
secure_create(Path(db_path))
+2 -1
View File
@@ -23,6 +23,7 @@ from typing import List
import yaml
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.parser import SkillParser
from openjarvis.skills.sources.base import ResolvedSkill
from openjarvis.skills.tool_translator import ToolTranslator
@@ -56,7 +57,7 @@ class SkillImporter:
self._parser = parser
self._translator = tool_translator
if target_root is None:
target_root = Path("~/.openjarvis/skills/").expanduser()
target_root = get_config_dir() / "skills"
self._target_root = Path(target_root)
def import_skill(
+4 -3
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.events import EventBus
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.dependency import validate_dependencies
from openjarvis.skills.executor import SkillExecutor, SkillResult
from openjarvis.skills.loader import discover_skills
@@ -54,7 +55,7 @@ class SkillManager:
except Exception:
pass
if overlay_dir is None:
overlay_dir = Path("~/.openjarvis/learning/skills/").expanduser()
overlay_dir = get_config_dir() / "learning" / "skills"
self._overlay_dir = Path(overlay_dir).expanduser()
# ------------------------------------------------------------------
@@ -262,7 +263,7 @@ class SkillManager:
discovered = discovery.analyze_traces(traces)
if output_dir is None:
output_dir = Path("~/.openjarvis/skills/discovered/").expanduser()
output_dir = get_config_dir() / "skills" / "discovered"
output_dir = Path(output_dir).expanduser()
output_dir.mkdir(parents=True, exist_ok=True)
@@ -375,7 +376,7 @@ class SkillManager:
manifest's ``name`` field equals ``name``.
"""
if roots is None:
roots = [Path("~/.openjarvis/skills/").expanduser(), Path("./skills")]
roots = [get_config_dir() / "skills", Path("./skills")]
matches: List[Path] = []
for root in roots:
+2 -1
View File
@@ -14,6 +14,7 @@ from typing import List
import yaml
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.sources.base import ResolvedSkill, SourceResolver
LOGGER = logging.getLogger(__name__)
@@ -28,7 +29,7 @@ class HermesResolver(SourceResolver):
def __init__(self, cache_root: Path | None = None) -> None:
if cache_root is None:
cache_root = Path("~/.openjarvis/skill-cache/hermes/").expanduser()
cache_root = get_config_dir() / "skill-cache" / "hermes"
self._cache_root = Path(cache_root)
def cache_dir(self) -> Path:
+2 -1
View File
@@ -15,6 +15,7 @@ from typing import List
import yaml
from openjarvis.core.paths import get_config_dir
from openjarvis.skills.sources.base import ResolvedSkill, SourceResolver
LOGGER = logging.getLogger(__name__)
@@ -29,7 +30,7 @@ class OpenClawResolver(SourceResolver):
def __init__(self, cache_root: Path | None = None) -> None:
if cache_root is None:
cache_root = Path("~/.openjarvis/skill-cache/openclaw/").expanduser()
cache_root = get_config_dir() / "skill-cache" / "openclaw"
self._cache_root = Path(cache_root)
def cache_dir(self) -> Path:
+2 -3
View File
@@ -7,6 +7,7 @@ from typing import Any, List, Optional
from openjarvis.core.config import JarvisConfig, load_config
from openjarvis.core.events import EventBus, get_event_bus
from openjarvis.core.paths import get_config_dir
from openjarvis.engine._stubs import InferenceEngine
from openjarvis.system.core import JarvisSystem
from openjarvis.tools._stubs import BaseTool, ToolExecutor
@@ -232,12 +233,10 @@ class SystemBuilder:
agent_manager = None
if config.agent_manager.enabled:
try:
from pathlib import Path
from openjarvis.agents.manager import AgentManager
am_db = config.agent_manager.db_path or str(
Path("~/.openjarvis/agents.db").expanduser()
get_config_dir() / "agents.db"
)
agent_manager = AgentManager(db_path=am_db)
except Exception as exc:
+3 -1
View File
@@ -11,6 +11,8 @@ try:
except ModuleNotFoundError:
import tomli as tomllib # type: ignore[no-redef]
from openjarvis.core.paths import get_config_dir
@dataclass(slots=True)
class AgentTemplate:
@@ -72,7 +74,7 @@ def _builtin_templates_dir() -> Path:
def _user_templates_dir() -> Path:
"""Return the path to user-defined templates (~/.openjarvis/templates/agents/)."""
return Path.home() / ".openjarvis" / "templates" / "agents"
return get_config_dir() / "templates" / "agents"
def discover_templates(
+3 -1
View File
@@ -19,6 +19,8 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from openjarvis.core.paths import get_config_dir
# ---------------------------------------------------------------------------
# Decision constants
# ---------------------------------------------------------------------------
@@ -147,7 +149,7 @@ class ApprovalStore:
def __init__(self, db_path: str = "") -> None:
if not db_path:
db_path = str(Path.home() / ".openjarvis" / "approvals.db")
db_path = str(get_config_dir() / "approvals.db")
self._db_path = db_path
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(db_path, check_same_thread=False)
+3 -3
View File
@@ -16,10 +16,10 @@ The TOML file format (written by the applier) is::
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Dict, Optional
from openjarvis.core.paths import get_config_dir
logger = logging.getLogger(__name__)
_cache: Optional[Dict[str, str]] = None
@@ -27,7 +27,7 @@ _cache: Optional[Dict[str, str]] = None
def _load_overrides() -> Dict[str, str]:
"""Parse descriptions.toml and return {tool_name: description}."""
home = Path(os.environ.get("OPENJARVIS_HOME", "~/.openjarvis")).expanduser()
home = get_config_dir()
desc_path = home / "tools" / "descriptions.toml"
if not desc_path.exists():
return {}
+4 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@@ -14,7 +15,9 @@ from openjarvis.tools._stubs import BaseTool, ToolSpec
class MemoryManageTool(BaseTool):
"""Manage persistent agent memory (MEMORY.md)."""
def __init__(self, memory_path: Path | str = "~/.openjarvis/MEMORY.md") -> None:
def __init__(self, memory_path: Path | str | None = None) -> None:
if memory_path is None:
memory_path = get_config_dir() / "MEMORY.md"
self._memory_path = Path(memory_path).expanduser()
@property
+4 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, List
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@@ -14,7 +15,9 @@ from openjarvis.tools._stubs import BaseTool, ToolSpec
class SkillManageTool(BaseTool):
"""Manage agent-authored procedural skills."""
def __init__(self, skills_dir: Path | str = "~/.openjarvis/skills/") -> None:
def __init__(self, skills_dir: Path | str | None = None) -> None:
if skills_dir is None:
skills_dir = get_config_dir() / "skills"
self._skills_dir = Path(skills_dir).expanduser()
@property
+4 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from openjarvis.core.paths import get_config_dir
from openjarvis.core.registry import ToolRegistry
from openjarvis.core.types import ToolResult
from openjarvis.tools._stubs import BaseTool, ToolSpec
@@ -14,7 +15,9 @@ from openjarvis.tools._stubs import BaseTool, ToolSpec
class UserProfileManageTool(BaseTool):
"""Manage persistent user profile (USER.md)."""
def __init__(self, user_path: Path | str = "~/.openjarvis/USER.md") -> None:
def __init__(self, user_path: Path | str | None = None) -> None:
if user_path is None:
user_path = get_config_dir() / "USER.md"
self._user_path = Path(user_path).expanduser()
@property
@@ -0,0 +1,273 @@
"""Regression tests for the connectors-router OAuth flow (issue #512).
These tests reproduce the three coupled defects that prevented Google Drive
(and its Google siblings) from ever completing OAuth and appearing in Data
Sources, and assert the fixed behaviour:
(A/B) ``POST /connect`` with a pasted ``client_id:client_secret`` pair must
persist the client credentials and return an ``oauth_required`` directive
pointing at ``/oauth/start`` NOT silently spawn a background browser
thread and report a perpetual ``pending`` state.
(C-1) ``GET /oauth/start`` must return a redirect to the provider's consent
page (regression: HTTP 422 because ``request: Request`` was mis-bound as
a query param under ``from __future__ import annotations`` + a local
``Request`` import).
(C-2) ``GET /oauth/callback`` must read ``request.base_url`` and exchange the
code for tokens without crashing (regression: ``request`` defaulted to
``None`` ``AttributeError``), persisting the access token to every
Google credential file and flipping ``is_connected()`` to True.
All tests are hermetic: the connectors directory, the shared Google
credentials path, and every Google connector's default credentials path are
redirected to ``tmp_path`` so the suite neither depends on nor pollutes
``~/.openjarvis/connectors`` (a real source of spurious failures see the
verifier note on ``resolve_google_credentials`` silently substituting the
shared file when the caller-supplied path does not yet exist on disk).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Iterator
from unittest.mock import patch
import pytest
fastapi = pytest.importorskip("fastapi", reason="requires the 'server' extra")
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
_CLIENT_PAIR = "myid-123.apps.googleusercontent.com:GOCSPX-secret"
_CLIENT_ID = "myid-123.apps.googleusercontent.com"
_ALL_GOOGLE_FILES = (
"google.json",
"gdrive.json",
"gcalendar.json",
"gcontacts.json",
"gmail.json",
"google_tasks.json",
)
@pytest.fixture()
def hermetic_connectors(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Redirect all Google credential paths into *tmp_path*.
Ensures connector instances created by the router's ``_get_or_create``
resolve to the same directory the OAuth callback writes to, and that the
test leaves ``~/.openjarvis`` untouched.
Why this is more than a one-line monkeypatch: the autouse registry-clear
fixture causes ``_ensure_connectors_registered()`` to ``importlib.reload``
each connector module on the first router call, which re-executes the
module body. To survive that reload we patch ``DEFAULT_CONFIG_DIR`` at its
*source* (``openjarvis.core.config``) every connector re-derives
``_DEFAULT_CREDENTIALS_PATH`` from it on reload, so the tmp dir sticks.
We also pre-register + pre-reload the connectors inside the fixture so the
reload happens while the patch is live, then reset module state on
teardown so a later test that imports these modules fresh is unaffected.
"""
import importlib
import sys
import openjarvis.connectors.oauth as oauth_mod
import openjarvis.core.config as config_mod
import openjarvis.server.connectors_router as router_mod
from openjarvis.core.registry import ConnectorRegistry
conn_dir = tmp_path / "connectors"
conn_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(config_mod, "DEFAULT_CONFIG_DIR", tmp_path)
monkeypatch.setattr(oauth_mod, "_CONNECTORS_DIR", conn_dir)
monkeypatch.setattr(
oauth_mod, "_SHARED_GOOGLE_CREDENTIALS_PATH", str(conn_dir / "google.json")
)
# Force the connector modules to re-derive their default paths from the
# patched DEFAULT_CONFIG_DIR now, before any request, and register them so
# the router's lazy reload-on-empty-registry path is a no-op.
google_mods = [
"openjarvis.connectors.gdrive",
"openjarvis.connectors.gcalendar",
"openjarvis.connectors.gcontacts",
"openjarvis.connectors.gmail",
"openjarvis.connectors.google_tasks",
]
for name in google_mods:
if name in sys.modules:
importlib.reload(sys.modules[name])
router_mod._instances.clear()
yield conn_dir
router_mod._instances.clear()
ConnectorRegistry.clear()
# Restore the connector modules to their real (unpatched) default paths so
# subsequent tests in the same process see ~/.openjarvis again.
for name in google_mods:
if name in sys.modules:
importlib.reload(sys.modules[name])
@pytest.fixture()
def client(hermetic_connectors: Path) -> Iterator[TestClient]:
from openjarvis.server.connectors_router import create_connectors_router
app = FastAPI()
app.include_router(create_connectors_router())
with TestClient(app) as c:
yield c
# ---------------------------------------------------------------------------
# Defect A/B — POST /connect must not silently spawn a background OAuth thread
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"connector_id", ["gdrive", "gcalendar", "gcontacts", "gmail", "google_tasks"]
)
def test_connect_client_pair_returns_oauth_required_no_browser(
client: TestClient, hermetic_connectors: Path, connector_id: str
) -> None:
"""Pasting client_id:secret persists creds + asks the UI to run the flow.
Covers every Google connector that shares the OAuth provider, proving the
sibling connectors are fixed too (not just gdrive).
"""
with patch("openjarvis.core.open_browser") as mock_browser:
resp = client.post(
f"/v1/connectors/{connector_id}/connect", json={"code": _CLIENT_PAIR}
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "oauth_required"
assert body["oauth_start"] == f"/v1/connectors/{connector_id}/oauth/start"
assert body["connected"] is False
# No fire-and-forget browser thread (the root cause of "nothing happens").
mock_browser.assert_not_called()
# Client credentials persisted to EVERY Google credential file so a single
# consent covers all Google connectors.
for filename in _ALL_GOOGLE_FILES:
path = hermetic_connectors / filename
assert path.exists(), f"{filename} not written"
assert json.loads(path.read_text())["client_id"] == _CLIENT_ID
def test_connect_malformed_client_pair_raises_400(
client: TestClient,
) -> None:
"""A blank secret surfaces an actionable 400 — not a silent pending state."""
resp = client.post(
"/v1/connectors/gdrive/connect",
json={"code": "myid-123.apps.googleusercontent.com:"},
)
assert resp.status_code == 400
assert "CLIENT_ID:CLIENT_SECRET" in resp.json()["detail"]
def test_connect_raw_token_still_handled(
client: TestClient, hermetic_connectors: Path
) -> None:
"""A raw token (not a client pair) still flows through handle_callback."""
resp = client.post(
"/v1/connectors/gdrive/connect", json={"token": "ya29.raw-access-token"}
)
assert resp.status_code == 200, resp.text
saved = json.loads((hermetic_connectors / "gdrive.json").read_text())
assert saved.get("token") == "ya29.raw-access-token"
# ---------------------------------------------------------------------------
# Defect C-1 — GET /oauth/start must redirect (was HTTP 422)
# ---------------------------------------------------------------------------
def test_oauth_start_redirects_to_consent(
client: TestClient,
) -> None:
# First save client creds via the connect call.
client.post("/v1/connectors/gdrive/connect", json={"code": _CLIENT_PAIR})
resp = client.get("/v1/connectors/gdrive/oauth/start", follow_redirects=False)
# FastAPI's RedirectResponse defaults to 307; any 3xx is a pass (was 422).
assert resp.status_code in (302, 307), resp.text
location = resp.headers["location"]
assert location.startswith("https://accounts.google.com/o/oauth2/v2/auth")
assert _CLIENT_ID in location
# redirect_uri must point back at OUR in-process callback.
assert "oauth%2Fcallback" in location or "oauth/callback" in location
def test_oauth_start_without_creds_returns_400(client: TestClient) -> None:
resp = client.get("/v1/connectors/gdrive/oauth/start", follow_redirects=False)
assert resp.status_code == 400
assert "client credentials" in resp.json()["detail"].lower()
# ---------------------------------------------------------------------------
# Defect C-2 — GET /oauth/callback must exchange + persist (was 500 on None)
# ---------------------------------------------------------------------------
def test_oauth_callback_exchanges_and_connects(
client: TestClient, hermetic_connectors: Path
) -> None:
import openjarvis.connectors.oauth as oauth_mod
client.post("/v1/connectors/gdrive/connect", json={"code": _CLIENT_PAIR})
fake_tokens = {
"access_token": "ya29.REAL",
"refresh_token": "1//REAL",
"token_type": "Bearer",
"expires_in": 3600,
}
with patch.object(oauth_mod, "_exchange_token", return_value=fake_tokens) as ex:
resp = client.get("/v1/connectors/gdrive/oauth/callback?code=authcode123")
assert resp.status_code == 200, resp.text
assert "Connected!" in resp.text
ex.assert_called_once()
# Access token written to ALL Google credential files.
for filename in _ALL_GOOGLE_FILES:
saved = json.loads((hermetic_connectors / filename).read_text())
assert saved["access_token"] == "ya29.REAL"
assert saved["refresh_token"] == "1//REAL"
# The connector now reports connected, and GET /connectors agrees.
from openjarvis.connectors.gdrive import GDriveConnector
assert GDriveConnector().is_connected() is True
listing = client.get("/v1/connectors").json()["connectors"]
gdrive = next(c for c in listing if c["connector_id"] == "gdrive")
assert gdrive["connected"] is True
def test_oauth_callback_error_param_renders_failure(client: TestClient) -> None:
resp = client.get("/v1/connectors/gdrive/oauth/callback?error=access_denied")
assert resp.status_code == 400
assert "access_denied" in resp.text
def test_oauth_callback_exchange_failure_renders_error(
client: TestClient,
) -> None:
import openjarvis.connectors.oauth as oauth_mod
client.post("/v1/connectors/gdrive/connect", json={"code": _CLIENT_PAIR})
def _boom(*_a: Any, **_k: Any) -> dict[str, Any]:
raise RuntimeError("token endpoint 400")
with patch.object(oauth_mod, "_exchange_token", side_effect=_boom):
resp = client.get("/v1/connectors/gdrive/oauth/callback?code=bad")
assert resp.status_code == 500
assert "Token Exchange Failed" in resp.text
+51 -37
View File
@@ -30,19 +30,35 @@ def test_exchange_google_token_calls_endpoint() -> None:
mock_post.assert_called_once()
def test_gdrive_handle_callback_triggers_oauth(tmp_path: Path) -> None:
def test_gdrive_handle_callback_persists_creds_no_background_flow(
tmp_path: Path,
) -> None:
"""A pasted client pair persists creds ONLY — no silent browser thread.
Regression for issue #512: the previous implementation spawned a daemon
thread that popped a browser and ran its own localhost:8789 callback
server. That thread failed silently in the bundled desktop context, so the
connector never gained an access token. ``handle_callback`` must now only
save the client_id/secret; the in-process server flow owns the consent
round-trip. We assert ``open_browser`` is never invoked.
"""
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
with patch("openjarvis.connectors.gdrive.run_oauth_flow") as mock_flow:
mock_flow.return_value = {"access_token": "ya29.test"}
with patch("openjarvis.core.open_browser") as mock_browser:
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_flow.assert_called_once()
call_kwargs = mock_flow.call_args
assert "test-id.apps.googleusercontent.com" in str(call_kwargs)
mock_browser.assert_not_called()
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
# No access token yet — that arrives via /oauth/callback.
assert not tokens.get("access_token")
assert conn.is_connected() is False
def test_gdrive_is_connected_requires_access_token(tmp_path: Path) -> None:
@@ -61,48 +77,46 @@ def test_gdrive_is_connected_requires_access_token(tmp_path: Path) -> None:
assert conn.is_connected() is True
def test_gcalendar_handle_callback_triggers_oauth(tmp_path: Path) -> None:
def test_gcalendar_handle_callback_persists_creds_no_background_flow(
tmp_path: Path,
) -> None:
"""Sibling connector shares the fix: creds saved, no browser thread (#512)."""
from openjarvis.connectors.gcalendar import GCalendarConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gcalendar.json")
conn = GCalendarConnector(credentials_path=creds)
with patch("openjarvis.connectors.gcalendar.run_oauth_flow") as mock_flow:
mock_flow.return_value = {"access_token": "ya29.test"}
with patch("openjarvis.core.open_browser") as mock_browser:
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_flow.assert_called_once()
def test_gcontacts_handle_callback_triggers_oauth(tmp_path: Path) -> None:
from openjarvis.connectors.gcontacts import GContactsConnector
creds = str(tmp_path / "gcontacts.json")
conn = GContactsConnector(credentials_path=creds)
with patch("openjarvis.connectors.gcontacts.run_oauth_flow") as mock_flow:
mock_flow.return_value = {"access_token": "ya29.test"}
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_flow.assert_called_once()
def test_gdrive_handle_callback_fallback_on_failure(tmp_path: Path) -> None:
from openjarvis.connectors.gdrive import GDriveConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gdrive.json")
conn = GDriveConnector(credentials_path=creds)
with patch("openjarvis.connectors.gdrive.run_oauth_flow") as mock_flow:
mock_flow.side_effect = RuntimeError("OAuth failed")
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
# Should have saved client_id and client_secret as fallback
mock_browser.assert_not_called()
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
assert conn.is_connected() is False
def test_gcontacts_handle_callback_persists_creds_no_background_flow(
tmp_path: Path,
) -> None:
"""Sibling connector shares the fix: creds saved, no browser thread (#512)."""
from openjarvis.connectors.gcontacts import GContactsConnector
from openjarvis.connectors.oauth import load_tokens
creds = str(tmp_path / "gcontacts.json")
conn = GContactsConnector(credentials_path=creds)
with patch("openjarvis.core.open_browser") as mock_browser:
conn.handle_callback("test-id.apps.googleusercontent.com:test-secret")
mock_browser.assert_not_called()
tokens = load_tokens(creds)
assert tokens is not None
assert tokens["client_id"] == "test-id.apps.googleusercontent.com"
assert tokens["client_secret"] == "test-secret"
assert conn.is_connected() is False
def test_gdrive_handle_callback_raw_token(tmp_path: Path) -> None:
+176
View File
@@ -0,0 +1,176 @@
"""Tests for the env-aware OpenJarvis home-directory resolver (issue #462).
Covers the single-root consolidation: ``$OPENJARVIS_HOME`` >
``$XDG_DATA_HOME/openjarvis`` > ``~/.openjarvis``, backward compatibility
(no env => exactly ``~/.openjarvis``), and the source-tree rejection guard.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from openjarvis.core import paths
def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Remove every env var that influences home resolution."""
for var in (
"OPENJARVIS_HOME",
"XDG_DATA_HOME",
"XDG_CONFIG_HOME",
"XDG_CACHE_HOME",
):
monkeypatch.delenv(var, raising=False)
class TestGetConfigDir:
"""Precedence and backward compatibility of get_config_dir()."""
def test_default_when_unset_is_legacy_dir(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
# Backward-compat: with nothing set, the resolved dir is exactly the
# historical ~/.openjarvis so existing installs are untouched.
_clear_env(monkeypatch)
assert paths.get_config_dir() == (Path.home() / ".openjarvis").resolve()
def test_respects_openjarvis_home(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
custom = tmp_path / "oj"
monkeypatch.setenv("OPENJARVIS_HOME", str(custom))
assert paths.get_config_dir() == custom.resolve()
def test_respects_xdg_data_home(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
# Single nested 'openjarvis' dir under XDG_DATA_HOME.
assert paths.get_config_dir() == (tmp_path / "openjarvis").resolve()
def test_openjarvis_home_wins_over_xdg(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
oj = tmp_path / "oj_wins"
monkeypatch.setenv("OPENJARVIS_HOME", str(oj))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg_loses"))
assert paths.get_config_dir() == oj.resolve()
def test_expands_user_in_openjarvis_home(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("OPENJARVIS_HOME", "~/relocated-oj")
assert paths.get_config_dir() == (Path.home() / "relocated-oj").resolve()
def test_returns_absolute_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "rel"))
assert paths.get_config_dir().is_absolute()
class TestDerivedDirs:
"""config_path / data_dir / cache_dir all hang off the single root."""
def test_config_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "oj"))
assert paths.get_config_path() == (tmp_path / "oj" / "config.toml").resolve()
def test_data_dir_equals_config_dir(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "oj"))
assert paths.get_data_dir() == paths.get_config_dir()
def test_cache_dir_is_nested_cache(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "oj"))
assert paths.get_cache_dir() == (tmp_path / "oj" / "cache").resolve()
def test_cache_dir_under_xdg(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path))
assert paths.get_cache_dir() == (tmp_path / "openjarvis" / "cache").resolve()
class TestSourceTreeRejection:
"""A home pointing inside the repo must fail loudly (REVIEW.md)."""
def test_rejects_path_inside_source_tree(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_env(monkeypatch)
source_root = paths._find_source_root()
assert source_root is not None # We must be running inside the repo.
monkeypatch.setenv("OPENJARVIS_HOME", str(source_root / "junk_dir"))
with pytest.raises(paths.ConfigurationError, match="inside the source tree"):
paths.get_config_dir()
class TestLegacyConstantsHonorEnv:
"""The legacy DEFAULT_CONFIG_* names route through the env-aware resolver.
This is the exact split-brain bug from #462: the constant used to ignore
OPENJARVIS_HOME entirely. The constant is resolved once at import (the
install-script model, where the env is set before the process starts), and
every instance-level default goes through ``get_config_dir()`` so it honors
the override. ``DEFAULT_CONFIG_DIR`` stays a real attribute so existing
tests can ``monkeypatch.setattr`` it.
"""
def test_constant_matches_resolver_at_import(self) -> None:
from openjarvis.core import config
# The constant is the import-time resolution of the same function.
assert config.DEFAULT_CONFIG_DIR == paths.get_config_dir()
assert config.DEFAULT_CONFIG_PATH == paths.get_config_path()
def test_constant_is_a_real_settable_attribute(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Install/CLI tests monkeypatch this attribute directly; it must be a
# real module attribute (not __getattr__-only) for setattr/undo to work.
from openjarvis.core import config
monkeypatch.setattr(config, "DEFAULT_CONFIG_DIR", tmp_path / "patched")
assert config.DEFAULT_CONFIG_DIR == tmp_path / "patched"
def test_dataclass_defaults_reflect_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Config dataclass field defaults must resolve under the override at
# instantiation time, not freeze ~/.openjarvis at import.
_clear_env(monkeypatch)
from openjarvis.core.config import SessionConfig, StorageConfig
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "oj"))
root = (tmp_path / "oj").resolve()
assert StorageConfig().db_path == str(root / "memory.db")
assert SessionConfig().db_path == str(root / "sessions.db")
def test_downstream_consumer_honors_env(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# End-to-end: a non-config subsystem (credentials) resolves under the
# custom root, proving the override is no longer split-brain.
_clear_env(monkeypatch)
from openjarvis.core import credentials
monkeypatch.setenv("OPENJARVIS_HOME", str(tmp_path / "oj"))
assert (
credentials._default_path()
== (tmp_path / "oj" / "credentials.toml").resolve()
)
+4 -1
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
from openjarvis.core.config import LearningConfig, SkillsLearningConfig
from openjarvis.core.paths import get_config_dir
class TestSkillsLearningConfig:
@@ -12,7 +13,9 @@ class TestSkillsLearningConfig:
assert cfg.optimizer == "dspy"
assert cfg.min_traces_per_skill == 20
assert cfg.optimization_interval_seconds == 86400
assert cfg.overlay_dir == "~/.openjarvis/learning/skills/"
# overlay_dir now resolves under the env-aware OpenJarvis root (#462),
# defaulting to <home>/learning/skills instead of the old literal.
assert cfg.overlay_dir == str(get_config_dir() / "learning" / "skills")
def test_can_be_constructed_with_all_fields(self):
cfg = SkillsLearningConfig(