Compare commits

...
12 Commits
Author SHA1 Message Date
Michaelandmichaelxer 39a802bea2 fix(tools): prune skipped dirs before descending in glob tool (#4538)
* fix(tools): prune skipped dirs before descending in glob tool

GlobTool used pathlib.Path.rglob which descends into every directory
(including node_modules, .git, dist, etc.) and filters AFTER the walk.
On repos with large junk directories this causes the glob tool to hang
for minutes.

Replace rglob with os.walk that prunes _CODENAV_SKIP_DIRS before
descending — matching the approach GrepTool already uses. Also add a
fast path for literal patterns (no wildcards → direct path lookup).

Fixes #4493

* fix(tools): use regex glob matching to fix * semantics and literal fallback

Replace fnmatch with _glob_to_regex so that * stays within a single
path segment (matching pathlib/rglob semantics) and **/ spans zero or
more directories.  Literal patterns now fall through to os.walk when
the direct path lookup misses, so e.g. 'foo.py' still finds files at
any depth.

Add tests for:
- bare literal matching in subdirectories
- multi-segment single-star patterns (sub/*.txt)
- * not crossing / boundaries
- ** matching at arbitrary depth

Closes #4493

---------

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-18 22:02:29 +02:00
RaresKeY 1cc8a373b0 fix(cookbook): validate agent SSH targets (#4429) 2026-06-18 21:41:33 +02:00
Wei Hong a52ac6822b fix(cookbook): pull llama.cpp from the ggml-org GHCR namespace (#4457) (#4490)
The Dependencies tab's llama.cpp docker recipe surfaced
\`docker pull ghcr.io/ggerganov/llama.cpp:server-cuda\`. The upstream
repo moved from github.com/ggerganov/llama.cpp to
github.com/ggml-org/llama.cpp and the old GHCR namespace no longer
publishes images, so copying the recipe failed with:

  failed to resolve reference "ghcr.io/ggerganov/llama.cpp:server-cuda":
  not found

Point the recipe at \`ghcr.io/ggml-org/llama.cpp:server-cuda\`, which is
already the namespace routes/cookbook_routes.py uses for the source
clone. Adds a regression test in the same shape as
test_cookbook_diagnosis_js.py asserting the new namespace and forbidding
the dead one.

No CSS/HTML/SVG/style changes — the file is a pure data module
(no DOM access) consumed by other renderers; only the displayed command
text changes.
2026-06-18 21:29:47 +02:00
Wei Hong 7475779b7c fix(chat): track chat hot-path background tasks for strong references (#4443) (#4444)
Two background tasks scheduled on every chat completion in
routes/chat_helpers.py — the memory/skill extraction dispatch and the
session auto-namer — are created via bare asyncio.create_task(...).
asyncio only holds a weak reference to the outer task, so the GC can
collect it mid-execution and the work silently never runs.

Add a module-private _BG_TASKS set and a _spawn_bg() helper that mirrors
WebhookManager._spawn_tracked (the pattern #3964 / #4336 established for
the webhook emitters two lines apart in the same function). Route both
call sites through it so the lifecycle owner is explicit.

Adds an AST-level guard test that fails on any bare
asyncio.create_task(...) statement in routes/chat_helpers.py to prevent
a regression — same shape as test_webhook_emitters_use_manager.py from
#4336.

The same bare pattern exists in routes/email_routes.py and
routes/cookbook_routes.py; left out of this PR per CONTRIBUTING.md's
"one fix per PR" and tracked in #4443's "Additional Information" for a
follow-up.
2026-06-18 21:26:11 +02:00
Christian ErikssonandClaude e7ffc69729 fix(cookbook): scope the "Kill vLLM" diagnosis to actual vLLM tracebacks (#4517)
The diagnosis panel offered a "Kill vLLM processes" (pkill -f vllm) recovery
for ANY Python traceback — including pip build failures and other tracebacks
that have nothing to do with vLLM. That advice is useless for a build failure
and harmful if an unrelated vLLM server happens to be running.

ERROR_PATTERNS in static/js/cookbook-diagnosis.js had one catch-all traceback
matcher that always attached the vLLM-kill fix. Split it into three (all
keeping the existing healthy-server suppression):
- pip build failure (Failed to build / metadata-generation-failed /
  subprocess-exited-with-error / Could not build wheels) -> "a dependency
  failed to build" message, no kill.
- vLLM-specific traceback (tail mentions vllm) -> keeps the kill, now scoped.
- any other traceback -> neutral "check the captured output" message, no kill.

How to test:
- node --check static/js/cookbook-diagnosis.js
- Trigger a wheel-build failure (old package on a newer Python) or a non-vLLM
  traceback and open the diagnosis. Before: generic traceback message + "Kill
  vLLM processes" button. After: a build-failure / neutral message with no kill;
  only a real vLLM traceback still offers it.

Fixes #4516

Co-authored-by: Claude
2026-06-18 21:18:14 +02:00
Karl Jussila 396e26b4bf fix(auth): tie remember-me cookie lifetime to TOKEN_TTL (#4472)
The persistent login cookie's max_age hardcoded 60 * 60 * 24 * 7, an
independent copy of the session token lifetime that core/auth.py already
defines once as TOKEN_TTL (and reports to the frontend via /api/auth/policy
as session_days). If TOKEN_TTL changes, the cookie silently drifts: the
browser keeps a cookie for a token whose lifetime no longer matches.

Import TOKEN_TTL and use it for the cookie max_age so the session lifetime
has a single source of truth. No behaviour change at the current value.

Fixes #4471
2026-06-18 21:15:48 +02:00
nubs 0bfc7750a2 fix(llm): route gpt-oss harmony commentary channel without leaking markers/tool-args (#4523)
The harmony stream router only recognized the analysis and final channels, so
gpt-oss's standard `commentary` channel (tool-call preambles / function-arg
bodies) was unhandled: the literal `<|channel|>commentary` marker, the
`to=functions.*` recipient, and the commentary body all leaked into the
visible answer. Add commentary to the marker regex + the suffix-hold table, and
route its body to thinking (only `final` is user-facing). Adds a regression
test (split-chunk + recipient + body), verified to fail without the fix.
2026-06-18 21:12:25 +02:00
Rolly Calma 790ef81b06 fix: use aware UTC in health timestamp (#4503) 2026-06-18 20:58:25 +02:00
Victor 804691501f test: stop test_skill_index_prompt_injection leaking a stub prefs_routes (#4387)
_patch_prefs installs a fake routes.prefs_routes with a bare
sys.modules[...] = assignment that is never undone. The stub is an empty
ModuleType without _save_for_user, so a later test whose code path runs
`from routes.prefs_routes import _save_for_user` (e.g. test_backup_import_skills)
fails with ImportError under an unfavorable test order.

Install the stub with monkeypatch.setitem instead (the helper already takes
monkeypatch and uses it for DATA_DIR) so it is reverted at teardown.

Repro: pytest tests/test_skill_index_prompt_injection.py tests/test_backup_import_skills.py
(1 failed before, 5 passed after).
2026-06-18 20:54:15 +02:00
dependabot[bot] 8e6a2e89f8 chore(deps): bump actions/checkout in the actions group (#4559)
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 20:49:58 +02:00
dependabot[bot] dbcc7874bf chore(deps): bump the npm group with 2 updates (#4558)
Bumps the npm group with 2 updates: [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) and [@antithesishq/bombadil](https://github.com/antithesishq/bombadil).


Updates `@anthropic-ai/sdk` from 0.104.1 to 0.105.0
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.104.1...sdk-v0.105.0)

Updates `@antithesishq/bombadil` from 0.5.0 to 0.6.1
- [Release notes](https://github.com/antithesishq/bombadil/releases)
- [Changelog](https://github.com/antithesishq/bombadil/blob/main/CHANGELOG.md)
- [Commits](https://github.com/antithesishq/bombadil/compare/v0.5.0...v0.6.1)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.105.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@antithesishq/bombadil"
  dependency-version: 0.6.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 20:42:49 +02:00
RaresKeY 16e660ad09 fix(hwfit): normalize CPU arch for fallback estimates (#4441) 2026-06-18 20:26:22 +02:00
31 changed files with 726 additions and 70 deletions
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
name: Python syntax (compileall) name: Python syntax (compileall)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -32,7 +32,7 @@ jobs:
name: JS syntax (node --check) name: JS syntax (node --check)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -54,7 +54,7 @@ jobs:
# ROADMAP "fresh install smoke tests" item; make this required once green. # ROADMAP "fresh install smoke tests" item; make this required once green.
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
+2 -2
View File
@@ -52,7 +52,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -93,7 +93,7 @@ jobs:
security-events: write # upload SARIF to the Security tab security-events: write # upload SARIF to the Security tab
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -55,7 +55,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
+2 -2
View File
@@ -45,7 +45,7 @@ jobs:
arch: arm64 arch: arm64
runner: ubuntu-24.04-arm runner: ubuntu-24.04-arm
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Buildx - name: Set up Buildx
@@ -86,7 +86,7 @@ jobs:
contents: read contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Read APP_VERSION + short sha - name: Read APP_VERSION + short sha
@@ -14,7 +14,7 @@ jobs:
# Skip bots (Dependabot, release-drafter, etc.) # Skip bots (Dependabot, release-drafter, etc.)
if: ${{ github.event.issue.user.type != 'Bot' }} if: ${{ github.event.issue.user.type != 'Bot' }}
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
persist-credentials: false persist-credentials: false
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
# Skip bots: they open PRs programmatically and have their own process. # Skip bots: they open PRs programmatically and have their own process.
if: github.event.pull_request.user.type != 'Bot' if: github.event.pull_request.user.type != 'Bot'
steps: steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ github.base_ref }} ref: ${{ github.base_ref }}
sparse-checkout: .github/scripts sparse-checkout: .github/scripts
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
# Full history so a secret committed in an earlier commit (and later # Full history so a secret committed in an earlier commit (and later
# deleted) is still caught -- deletion does not remove it from Git. # deleted) is still caught -- deletion does not remove it from Git.
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
@@ -61,7 +61,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
+2 -2
View File
@@ -39,7 +39,7 @@ load_dotenv(encoding="utf-8-sig")
import asyncio import asyncio
import logging import logging
import secrets import secrets
from datetime import datetime from datetime import datetime, timezone
from typing import Dict from typing import Dict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -863,7 +863,7 @@ async def get_version():
@app.get("/api/health") @app.get("/api/health")
async def health_check() -> Dict[str, str]: async def health_check() -> Dict[str, str]:
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
@app.get("/api/ready") @app.get("/api/ready")
async def readiness_check() -> JSONResponse: async def readiness_check() -> JSONResponse:
+8 -8
View File
@@ -5,16 +5,16 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.104.1" "@anthropic-ai/sdk": "^0.105.0"
}, },
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.5.0" "@antithesishq/bombadil": "^0.6.1"
} }
}, },
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
"version": "0.104.1", "version": "0.105.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.105.0.tgz",
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==", "integrity": "sha512-sDyu+aM9cE6uZE+HgRjjHRb+qqb87GHZOx+8bE0YlWetdL1YcVLxn8h9ltxGOflyChTe6PMEo50kMQV4cw0hfg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"json-schema-to-ts": "^3.1.1", "json-schema-to-ts": "^3.1.1",
@@ -33,9 +33,9 @@
} }
}, },
"node_modules/@antithesishq/bombadil": { "node_modules/@antithesishq/bombadil": {
"version": "0.5.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz", "resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.6.1.tgz",
"integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==", "integrity": "sha512-d1iufG3MI7gSMSiSmMeNdcMW+qR0yQXL2zdkVynC3n3DYgFJYlYXKUQzygmqU12m4RWlR5iOdQU1hsx5UT6+IA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
+2 -2
View File
@@ -4,9 +4,9 @@
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git" "url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
}, },
"devDependencies": { "devDependencies": {
"@antithesishq/bombadil": "^0.5.0" "@antithesishq/bombadil": "^0.6.1"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.104.1" "@anthropic-ai/sdk": "^0.105.0"
} }
} }
+2 -2
View File
@@ -12,7 +12,7 @@ import re
from pathlib import Path from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
from src.rate_limiter import RateLimiter from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings from src.settings_scrub import scrub_settings
@@ -161,7 +161,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
path="/", path="/",
) )
if body.remember: if body.remember:
cookie_kwargs["max_age"] = 60 * 60 * 24 * 7 # 7 days cookie_kwargs["max_age"] = TOKEN_TTL
response.set_cookie(**cookie_kwargs) response.set_cookie(**cookie_kwargs)
return {"ok": True, "username": username} return {"ok": True, "username": username}
+18 -2
View File
@@ -23,6 +23,22 @@ from fastapi import HTTPException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Strong references to in-flight fire-and-forget tasks scheduled from this
# module. asyncio only keeps weak references to tasks created via
# create_task, so without this the GC can collect a task mid-execution and
# the background work (extraction, auto-naming) silently never runs.
# Mirrors WebhookManager._spawn_tracked from src/webhook_manager.py.
_BG_TASKS: set[asyncio.Task] = set()
def _spawn_bg(coro) -> asyncio.Task:
"""Schedule a background task and hold a strong reference until it finishes."""
task = asyncio.create_task(coro)
_BG_TASKS.add(task)
task.add_done_callback(_BG_TASKS.discard)
return task
# ── Data containers ────────────────────────────────────────────────────── # # ── Data containers ────────────────────────────────────────────────────── #
@dataclass @dataclass
@@ -1105,7 +1121,7 @@ def run_post_response_tasks(
))) )))
if _extraction_jobs: if _extraction_jobs:
asyncio.create_task(_run_extraction_jobs_sequentially(session_id, _extraction_jobs)) _spawn_bg(_run_extraction_jobs_sequentially(session_id, _extraction_jobs))
# Token accumulation # Token accumulation
if last_metrics: if last_metrics:
@@ -1120,4 +1136,4 @@ def run_post_response_tasks(
# Auto-name # Auto-name
if needs_auto_name(sess.name): if needs_auto_name(sess.name):
asyncio.create_task(auto_name_session(session_manager, sess)) _spawn_bg(auto_name_session(session_manager, sess))
+4 -3
View File
@@ -146,17 +146,18 @@ def _canonical_cpu_backend(system):
if backend in ("cpu_x86", "cpu_arm"): if backend in ("cpu_x86", "cpu_arm"):
return backend return backend
# Raw CPU-architecture aliases # Raw CPU-architecture aliases. Treat plain "arm" as 32-bit ARM, not the
# ARM64-class CPU fallback used for Apple Silicon/aarch64 machines.
if backend in ("x86_64", "amd64", "i386", "i686"): if backend in ("x86_64", "amd64", "i386", "i686"):
return "cpu_x86" return "cpu_x86"
if backend in ("arm64", "aarch64", "arm"): if backend in ("arm64", "aarch64"):
return "cpu_arm" return "cpu_arm"
# Prefer an explicit CPU architecture field when present # Prefer an explicit CPU architecture field when present
if cpu_arch: if cpu_arch:
if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"): if cpu_arch in ("x86_64", "amd64", "x86", "i386", "i686"):
return "cpu_x86" return "cpu_x86"
if cpu_arch in ("arm64", "aarch64", "arm"): if cpu_arch in ("arm64", "aarch64"):
return "cpu_arm" return "cpu_arm"
# Apple Silicon enters ranking as backend="metal"; its CPU path is ARM. # Apple Silicon enters ranking as backend="metal"; its CPU path is ARM.
+26 -7
View File
@@ -320,7 +320,7 @@ def _detect_apple_silicon():
# Only Apple Silicon (arm64) has a Metal GPU worth serving LLMs on; Intel # Only Apple Silicon (arm64) has a Metal GPU worth serving LLMs on; Intel
# Macs fall through to the CPU path. # Macs fall through to the CPU path.
if "arm" not in arch and "aarch64" not in arch: if _canonical_cpu_arch(arch) != "arm64":
return None return None
# Chip name, e.g. "Apple M4 Max" — carries the Pro/Max/Ultra variant that # Chip name, e.g. "Apple M4 Max" — carries the Pro/Max/Ultra variant that
@@ -503,6 +503,25 @@ def _get_cpu_count():
return os.cpu_count() or 1 return os.cpu_count() or 1
def _canonical_cpu_arch(value):
arch = str(value or "").lower().strip().replace("-", "_")
if arch in ("x86_64", "amd64", "x64"):
return "x86_64"
if arch in ("i386", "i686", "x86"):
return "x86"
if arch in ("arm64", "aarch64"):
return "arm64"
if arch == "arm" or arch.startswith("armv"):
return "arm"
return arch
def _get_cpu_arch():
if _remote_host:
return _canonical_cpu_arch(_run(["uname", "-m"]) or "")
return _canonical_cpu_arch(platform.machine())
def _powershell_exe(): def _powershell_exe():
"""Pick the best PowerShell executable for LOCAL execution: prefer pwsh """Pick the best PowerShell executable for LOCAL execution: prefer pwsh
(PowerShell 7+), fall back to Windows PowerShell 5.1. Returns an absolute (PowerShell 7+), fall back to Windows PowerShell 5.1. Returns an absolute
@@ -528,6 +547,7 @@ def _detect_windows():
$r.cpu_name = $cpu.Name $r.cpu_name = $cpu.Name
$r.cpu_cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum $r.cpu_cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$r.arch = $cpu.AddressWidth $r.arch = $cpu.AddressWidth
$r.cpu_arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE }
# GPU detection via nvidia-smi (fastest) or WMI fallback # GPU detection via nvidia-smi (fastest) or WMI fallback
try { try {
$nv = nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits 2>$null $nv = nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits 2>$null
@@ -599,6 +619,7 @@ def _detect_windows():
"available_ram_gb": d.get("avail_gb", 0), "available_ram_gb": d.get("avail_gb", 0),
"cpu_cores": _as_int(d.get("cpu_cores"), 1), "cpu_cores": _as_int(d.get("cpu_cores"), 1),
"cpu_name": _cpu_name, "cpu_name": _cpu_name,
"cpu_arch": _canonical_cpu_arch(d.get("cpu_arch")),
"has_gpu": bool(d.get("gpu_name")), "has_gpu": bool(d.get("gpu_name")),
"gpu_name": d.get("gpu_name"), "gpu_name": d.get("gpu_name"),
"gpu_vram_gb": d.get("gpu_vram_gb"), "gpu_vram_gb": d.get("gpu_vram_gb"),
@@ -794,6 +815,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
available_ram = round(_get_available_ram_gb(), 1) available_ram = round(_get_available_ram_gb(), 1)
cpu_cores = _get_cpu_count() cpu_cores = _get_cpu_count()
cpu_name = _get_cpu_name() cpu_name = _get_cpu_name()
cpu_arch = _get_cpu_arch()
gpu_info = _detect_apple_silicon() or _detect_nvidia() or _detect_amd() gpu_info = _detect_apple_silicon() or _detect_nvidia() or _detect_amd()
@@ -803,6 +825,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"available_ram_gb": available_ram, "available_ram_gb": available_ram,
"cpu_cores": cpu_cores, "cpu_cores": cpu_cores,
"cpu_name": cpu_name, "cpu_name": cpu_name,
"cpu_arch": cpu_arch,
"has_gpu": True, "has_gpu": True,
"gpu_name": gpu_info["gpu_name"], "gpu_name": gpu_info["gpu_name"],
"gpu_vram_gb": gpu_info["gpu_vram_gb"], "gpu_vram_gb": gpu_info["gpu_vram_gb"],
@@ -817,17 +840,13 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"unified_memory": gpu_info.get("unified_memory", False), "unified_memory": gpu_info.get("unified_memory", False),
} }
else: else:
if _remote_host: backend = "cpu_arm" if cpu_arch == "arm64" else "cpu_x86"
arch_out = _run(["uname", "-m"]) or ""
else:
import platform as _platform
arch_out = _platform.machine().lower()
backend = "cpu_arm" if "aarch64" in arch_out or "arm" in arch_out else "cpu_x86"
result = { result = {
"total_ram_gb": total_ram, "total_ram_gb": total_ram,
"available_ram_gb": available_ram, "available_ram_gb": available_ram,
"cpu_cores": cpu_cores, "cpu_cores": cpu_cores,
"cpu_name": cpu_name, "cpu_name": cpu_name,
"cpu_arch": cpu_arch,
"has_gpu": False, "has_gpu": False,
"gpu_name": None, "gpu_name": None,
"gpu_vram_gb": None, "gpu_vram_gb": None,
+54 -13
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
import os import os
import re
import difflib import difflib
import fnmatch import fnmatch
import shutil import shutil
@@ -16,6 +17,31 @@ _CODENAV_SKIP_DIRS = frozenset({
_CODENAV_MAX_HITS = 200 _CODENAV_MAX_HITS = 200
_CODENAV_MAX_LINE = 400 _CODENAV_MAX_LINE = 400
def _glob_to_regex(pat: str) -> "re.Pattern":
"""Translate a forward-slash glob (**, *, ?) into a compiled regex.
`**/` matches zero or more complete directories.
`*` matches within a single path segment (does not cross /).
"""
i, n, out = 0, len(pat), []
while i < n:
if pat[i : i + 3] == "**/":
out.append("(?:[^/]+/)*")
i += 3
elif pat[i : i + 2] == "**":
out.append(".*")
i += 2
elif pat[i] == "*":
out.append("[^/]*")
i += 1
elif pat[i] == "?":
out.append("[^/]")
i += 1
else:
out.append(re.escape(pat[i]))
i += 1
return re.compile("".join(out))
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]: def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
if old == new: if old == new:
return None return None
@@ -259,23 +285,38 @@ class GlobTool:
return {"error": f"glob: {e}", "exit_code": 1} return {"error": f"glob: {e}", "exit_code": 1}
def _glob(): def _glob():
from pathlib import Path base = os.path.abspath(root)
base = Path(root) if not os.path.isdir(base):
if not base.is_dir():
return None, f"glob: {root}: not a directory" return None, f"glob: {root}: not a directory"
norm_pat = pattern.replace("\\", "/")
# Fast path: literal pattern (no wildcards) → direct path lookup.
if not any(c in norm_pat for c in "*?["):
cand = os.path.normpath(os.path.join(base, norm_pat))
if os.path.exists(cand):
return [cand], None
# Literal not at exact path — fall through to walk so
# e.g. "foo.py" still matches at any depth (like rglob).
# Compile glob to regex: * stays within one segment, **/ spans dirs.
regex = _glob_to_regex(norm_pat)
matched = [] matched = []
cap = _CODENAV_MAX_HITS * 5
try: try:
for p in base.rglob(pattern): for dp, dns, fns in os.walk(base):
if set(p.relative_to(base).parts) & _CODENAV_SKIP_DIRS: # Prune skipped dirs before descending (unlike rglob which
continue # descends first then filters — fatal on large node_modules).
try: dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
mtime = p.stat().st_mtime for name in fns + dns:
except OSError: full = os.path.join(dp, name)
mtime = 0 rel = os.path.relpath(full, base).replace(os.sep, "/")
matched.append((mtime, str(p))) if regex.fullmatch(rel) or regex.fullmatch(name):
if len(matched) > _CODENAV_MAX_HITS * 5: try:
mtime = os.stat(full).st_mtime
except OSError:
mtime = 0
matched.append((mtime, full))
if len(matched) > cap:
break break
except (OSError, ValueError) as _e: except OSError as _e:
return None, f"glob: {_e}" return None, f"glob: {_e}"
matched.sort(key=lambda t: t[0], reverse=True) matched.sort(key=lambda t: t[0], reverse=True)
return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None
+6 -2
View File
@@ -87,7 +87,7 @@ _host_health_lock = threading.Lock()
_model_activity: Dict[str, float] = {} _model_activity: Dict[str, float] = {}
_HARMONY_MARKER_RE = re.compile( _HARMONY_MARKER_RE = re.compile(
r"<\|channel\|>(analysis|final)" r"<\|channel\|>(analysis|commentary|final)"
r"|<\|start\|>(?:assistant|system|user|tool)?" r"|<\|start\|>(?:assistant|system|user|tool)?"
r"|<\|message\|>" r"|<\|message\|>"
r"|<\|end\|>" r"|<\|end\|>"
@@ -96,6 +96,7 @@ _HARMONY_MARKER_RE = re.compile(
) )
_HARMONY_MARKERS = ( _HARMONY_MARKERS = (
"<|channel|>analysis", "<|channel|>analysis",
"<|channel|>commentary",
"<|channel|>final", "<|channel|>final",
"<|start|>assistant", "<|start|>assistant",
"<|start|>system", "<|start|>system",
@@ -145,7 +146,10 @@ class _HarmonyStreamRouter:
out.append((text, False)) out.append((text, False))
return return
if self._in_message: if self._in_message:
out.append((text, self._channel == "analysis")) # analysis + commentary (tool-call preambles / function-arg bodies)
# are internal, not user-facing — route them to thinking so they
# don't leak into the visible answer; only `final` is visible.
out.append((text, self._channel in ("analysis", "commentary")))
def _handle_marker(self, match: re.Match[str]) -> None: def _handle_marker(self, match: re.Match[str]) -> None:
marker = match.group(0) marker = match.group(0)
+31 -3
View File
@@ -12,12 +12,24 @@ import os
import re import re
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import HTTPException
from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE from src.constants import MAX_READ_CHARS, DEEP_RESEARCH_DIR, VAULT_FILE
from src.tool_utils import get_mcp_manager from src.tool_utils import get_mcp_manager
from core.constants import internal_api_base from core.constants import internal_api_base
from routes._validators import validate_remote_host, validate_ssh_port
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _string_arg(value: Any) -> str:
return "" if value is None else str(value).strip()
def _validate_cookbook_ssh_target(remote_host: Any, ssh_port: Any = "") -> tuple[str, str]:
remote = validate_remote_host(_string_arg(remote_host) or None) or ""
sport = validate_ssh_port(_string_arg(ssh_port) or None) or ""
return remote, sport
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Active email state # Active email state
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -3025,6 +3037,10 @@ async def _cookbook_kill_session(session_id: str, *, remote_host: str = "",
break break
if remote: if remote:
try:
remote, sport = _validate_cookbook_ssh_target(remote, sport)
except HTTPException as e:
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
_pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else "" _pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
cmd = ( cmd = (
f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no " f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
@@ -3113,8 +3129,8 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
tail = 400 tail = 400
tail = max(20, min(tail, 4000)) tail = max(20, min(tail, 4000))
headers = _internal_headers() headers = _internal_headers()
remote = (args.get("remote_host") or args.get("host") or "").strip() remote = _string_arg(args.get("remote_host") or args.get("host"))
sport = (args.get("ssh_port") or "").strip() sport = _string_arg(args.get("ssh_port"))
# Resolve host from cookbook state if caller didn't pass one — same # Resolve host from cookbook state if caller didn't pass one — same
# lookup _cookbook_kill_session uses. # lookup _cookbook_kill_session uses.
if not remote: if not remote:
@@ -3132,6 +3148,12 @@ async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dic
if not sport: if not sport:
sport = t.get("sshPort") or "" sport = t.get("sshPort") or ""
break break
if remote:
try:
remote, sport = _validate_cookbook_ssh_target(remote, sport)
except HTTPException as e:
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
# Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the # Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
# live tmux pane. The pane is what the user would see scrolling on # live tmux pane. The pane is what the user would see scrolling on
# their screen — including the post-crash neofetch banner and the # their screen — including the post-crash neofetch banner and the
@@ -3309,7 +3331,7 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
except ValueError: except ValueError:
return {"error": "Invalid JSON arguments", "exit_code": 1} return {"error": "Invalid JSON arguments", "exit_code": 1}
host = (args.get("host") or args.get("remote_host") or "").strip() host = _string_arg(args.get("host") or args.get("remote_host"))
sess = (args.get("tmux_session") or args.get("session_id") or "").strip() sess = (args.get("tmux_session") or args.get("session_id") or "").strip()
model = (args.get("model") or args.get("repo_id") or "").strip() model = (args.get("model") or args.get("repo_id") or "").strip()
port = args.get("port") or 8000 port = args.get("port") or 8000
@@ -3320,6 +3342,12 @@ async def do_adopt_served_model(content: str, owner: Optional[str] = None) -> Di
return {"error": "tmux_session and model are required", "exit_code": 1} return {"error": "tmux_session and model are required", "exit_code": 1}
# Verify tmux session exists on the target host # Verify tmux session exists on the target host
if host:
try:
host, _ = _validate_cookbook_ssh_target(host)
except HTTPException as e:
return {"error": str(getattr(e, "detail", e)), "exit_code": 1}
headers = _internal_headers() headers = _internal_headers()
if host: if host:
check = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)} 2>&1'" check = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)} 2>&1'"
+1 -1
View File
@@ -56,7 +56,7 @@ const _RECIPES = [
match: () => true, match: () => true,
variants: { variants: {
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] }, pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] }, docker: { commands: ['docker pull ghcr.io/ggml-org/llama.cpp:server-cuda'] },
}, },
}, },
]; ];
+35 -9
View File
@@ -578,24 +578,50 @@ export const ERROR_PATTERNS = [
], ],
}, },
{ {
// Tail-only + healthy-server suppression. tmux capture-pane returns the // Dependency-install (pip) build failure — a required package failed to
// entire scrollback every poll, so a one-shot startup traceback would // build its wheel (common when an old sdist's setup.py breaks on a newer
// otherwise stick on the panel forever even while the server happily // Python, e.g. basicsr on 3.13). This is an install problem, NOT a serve
// serves /v1/models. Only fire if the traceback is in recent output AND // problem, so it must never suggest killing vLLM.
// the server isn't currently logging healthy traffic. match: (text) => {
const TAIL = text.slice(-6000);
// A serve script can run a fallback build and then start serving fine —
// don't flag a stale build error once the server is up.
if (/Application startup complete|"(?:GET|POST)\s+\/v1\/[^"]+ HTTP\/[\d.]+"\s*2\d\d|Uvicorn running on|server is listening on https?:\/\//i.test(TAIL)) return false;
return /Failed to build\b|subprocess-exited-with-error|Could not build wheels|metadata-generation-failed/i.test(TAIL);
},
message: 'A dependency failed to build during install — usually an older package whose build breaks on this Python version, not a server problem. The install did not finish.',
suggestion: 'Suggested action: check the captured output for the package that failed to build; it may need a newer release or a patch to install on this Python version.',
fixes: [],
},
{
// vLLM-specific traceback: only offer the kill-processes recovery when the
// output is actually about vLLM. Tail-only + healthy-server suppression so
// a one-shot startup traceback doesn't stick on the panel forever while
// the server happily serves /v1/models.
match: (text) => { match: (text) => {
const TAIL = text.slice(-4096); const TAIL = text.slice(-4096);
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false; if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
// Healthy markers in the tail mean whatever blew up has been recovered
// from — the server is up and answering requests.
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false; if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
return true; return /vllm/i.test(TAIL);
}, },
message: 'Python traceback detected — may be a handled error, check logs.', message: 'A vLLM process hit a Python traceback and may be wedged.',
fixes: [ fixes: [
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') }, { label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
], ],
}, },
{
// Generic traceback (not vLLM, not a pip build): surface it without
// suggesting an unrelated vLLM kill. Same tail-only + healthy suppression.
match: (text) => {
const TAIL = text.slice(-4096);
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
return true;
},
message: 'Python traceback detected — check the captured output below for the underlying error.',
suggestion: 'Suggested action: read the captured output for the failing step; copy the troubleshooting bundle if you need help.',
fixes: [],
},
]; ];
export function _diagnose(text) { export function _diagnose(text) {
+55
View File
@@ -215,3 +215,58 @@ def test_setup_rejects_seven_char_password(tmp_path):
asyncio.run(endpoint(body=body, request=request)) asyncio.run(endpoint(body=body, request=request))
assert exc.value.status_code == 400 assert exc.value.status_code == 400
# ── Login "remember me" cookie lifetime ────────────────────────────────
class _CapturingResponse:
"""Stand-in for fastapi.Response that records set_cookie kwargs."""
def __init__(self):
self.cookie_kwargs = None
def set_cookie(self, **kwargs):
self.cookie_kwargs = kwargs
def _login_endpoint(auth_manager):
sys.modules.pop("routes.auth_routes", None)
_real_core_package()
from routes.auth_routes import LoginRequest, setup_auth_routes
router = setup_auth_routes(auth_manager)
for route in router.routes:
if getattr(route, "path", None) == "/api/auth/login":
return route.endpoint, LoginRequest
raise AssertionError("login route not found")
def test_remember_cookie_max_age_matches_token_ttl(tmp_path):
auth_mod = _auth_module()
mgr = _make_manager(tmp_path)
mgr.create_user("alice", "alice-password", is_admin=False)
endpoint, LoginRequest = _login_endpoint(mgr)
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
response = _CapturingResponse()
body = LoginRequest(username="alice", password="alice-password", remember=True)
result = asyncio.run(endpoint(body=body, request=request, response=response))
assert result == {"ok": True, "username": "alice"}
# The persistent cookie must outlive neither more nor less than the token.
assert response.cookie_kwargs["max_age"] == auth_mod.TOKEN_TTL
def test_no_remember_omits_cookie_max_age(tmp_path):
mgr = _make_manager(tmp_path)
mgr.create_user("bob", "bob-password", is_admin=False)
endpoint, LoginRequest = _login_endpoint(mgr)
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
response = _CapturingResponse()
body = LoginRequest(username="bob", password="bob-password", remember=False)
asyncio.run(endpoint(body=body, request=request, response=response))
# Without "remember", the cookie is a session cookie (no max_age).
assert "max_age" not in response.cookie_kwargs
@@ -0,0 +1,71 @@
"""Guard: chat hot-path background tasks must go through _spawn_bg.
asyncio only holds a weak reference to a bare create_task() result, so the
GC can collect the outer task before its body runs and the background work
(memory/skill extraction, session auto-naming) silently never happens.
routes/chat_helpers.py owns these schedules via _spawn_bg(), which adds the
task to _BG_TASKS and discards it via a done-callback. This guard catches a
regression where a copy-paste re-introduces a bare asyncio.create_task.
This is the routes/chat_helpers.py-scoped sibling of the webhook-emitter
guard added in #4336 (tests/test_webhook_emitters_use_manager.py).
"""
import ast
from pathlib import Path
CHAT_HELPERS = (
Path(__file__).resolve().parent.parent / "routes" / "chat_helpers.py"
)
def _untracked_create_task_calls(tree: ast.AST) -> list[tuple[int, str]]:
"""(lineno, snippet) for any bare asyncio.create_task(...).
A call is "bare" when its return value is dropped — i.e. it is the direct
expression of an ast.Expr statement. Captured forms (`x = asyncio.create_task(...)`,
`[asyncio.create_task(...), ...]`, `await asyncio.create_task(...)`) are fine
because something else holds the reference.
The helper itself (_spawn_bg) is exempt: it calls asyncio.create_task once
and registers the task in _BG_TASKS before returning.
"""
hits: list[tuple[int, str]] = []
def _is_create_task(call: ast.Call) -> bool:
f = call.func
return (
isinstance(f, ast.Attribute)
and f.attr == "create_task"
and isinstance(f.value, ast.Name)
and f.value.id == "asyncio"
)
spawn_helper_lines: set[int] = set()
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "_spawn_bg":
for n in ast.walk(node):
if hasattr(n, "lineno"):
spawn_helper_lines.add(n.lineno)
for node in ast.walk(tree):
if not isinstance(node, ast.Expr):
continue
if not isinstance(node.value, ast.Call):
continue
if not _is_create_task(node.value):
continue
if node.lineno in spawn_helper_lines:
continue
hits.append((node.lineno, ast.unparse(node.value)))
return hits
def test_no_untracked_create_task_in_chat_helpers():
tree = ast.parse(CHAT_HELPERS.read_text(), filename=str(CHAT_HELPERS))
offenders = _untracked_create_task_calls(tree)
assert not offenders, (
"Background tasks scheduled from routes/chat_helpers.py must go through "
"_spawn_bg(coro) so the task is registered in _BG_TASKS and survives until "
"it finishes. Found bare asyncio.create_task(...) call(s):\n "
+ "\n ".join(f"chat_helpers.py:{ln}: {snip}" for ln, snip in offenders)
)
+34
View File
@@ -24,6 +24,9 @@ def repo():
os.mkdir(os.path.join(root, "sub")) os.mkdir(os.path.join(root, "sub"))
with open(os.path.join(root, "sub", "b.txt"), "w") as f: with open(os.path.join(root, "sub", "b.txt"), "w") as f:
f.write("nothing\nNEEDLE upper\n") f.write("nothing\nNEEDLE upper\n")
os.mkdir(os.path.join(root, "sub", "deep"))
with open(os.path.join(root, "sub", "deep", "c.py"), "w") as f:
f.write("# deep python\n")
os.mkdir(os.path.join(root, "node_modules")) os.mkdir(os.path.join(root, "node_modules"))
with open(os.path.join(root, "node_modules", "dep.py"), "w") as f: with open(os.path.join(root, "node_modules", "dep.py"), "w") as f:
f.write("needle in dep\n") f.write("needle in dep\n")
@@ -107,6 +110,37 @@ def test_glob_requires_pattern(repo):
assert r["exit_code"] == 1 assert r["exit_code"] == 1
def test_glob_literal_in_subdir(repo):
"""Bare literal should match at any depth (like rglob), not only at root."""
r = _run("glob", f'{{"pattern": "b.txt", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "b.txt" in r["output"]
def test_glob_multi_segment_single_star(repo):
"""sub/*.txt matches sub/b.txt but NOT sub/deep/c.py (single * stays in one segment)."""
r = _run("glob", f'{{"pattern": "sub/*.txt", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "b.txt" in r["output"]
assert "c.py" not in r["output"]
def test_glob_star_does_not_cross_slash(repo):
"""src/*.py must NOT match src/a/b/x.py — * is single-segment only."""
r = _run("glob", f'{{"pattern": "sub/*.py", "path": "{repo}"}}')
assert r["exit_code"] == 0
# sub/ has no .py directly, only sub/deep/c.py — should NOT match
assert "No files matching" in r["output"]
def test_glob_double_star_matches_deep(repo):
"""**/*.py should match files at any depth."""
r = _run("glob", f'{{"pattern": "**/*.py", "path": "{repo}"}}')
assert r["exit_code"] == 0
assert "a.py" in r["output"]
assert "c.py" in r["output"]
# ── ls ──────────────────────────────────────────────────────────────────── # ── ls ────────────────────────────────────────────────────────────────────
def test_ls_lists_entries(repo): def test_ls_lists_entries(repo):
@@ -0,0 +1,215 @@
import json
import pytest
from src import tool_implementations as tools
class FakeResponse:
def __init__(self, data=None, status_code=200):
self._data = data or {}
self.status_code = status_code
self.text = json.dumps(self._data)
self.content = self.text.encode("utf-8")
self.headers = {"content-type": "application/json"}
def json(self):
return self._data
def _install_httpx_client(monkeypatch, *, state=None, posts=None):
import httpx
posts = posts if posts is not None else []
state = state if state is not None else {"tasks": []}
class FakeAsyncClient:
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url, **kwargs):
return FakeResponse(state)
async def post(self, url, json=None, **kwargs):
posts.append((url, json, kwargs))
return FakeResponse({"stdout": "", "stderr": "", "exit_code": 0})
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
return posts
@pytest.mark.asyncio
async def test_stop_served_model_rejects_invalid_remote_host_before_shell(monkeypatch):
posts = _install_httpx_client(monkeypatch)
result = await tools.do_stop_served_model(
json.dumps({"session_id": "serve-abc123", "remote_host": "-bad"})
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_stop_served_model_rejects_invalid_state_host_before_shell(monkeypatch):
posts = _install_httpx_client(
monkeypatch,
state={
"tasks": [
{
"sessionId": "serve-abc123",
"remoteHost": "-bad",
"sshPort": "22",
}
]
},
)
result = await tools.do_stop_served_model(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_stop_served_model_rejects_invalid_ssh_port_before_shell(monkeypatch):
posts = _install_httpx_client(monkeypatch)
result = await tools.do_stop_served_model(
json.dumps(
{
"session_id": "serve-abc123",
"remote_host": "gpu-box",
"ssh_port": "not-a-port",
}
)
)
assert result["exit_code"] == 1
assert "Invalid ssh_port" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_stop_served_model_uses_validated_remote_target(monkeypatch):
posts = _install_httpx_client(monkeypatch)
result = await tools.do_stop_served_model(
json.dumps(
{
"session_id": "serve-abc123",
"remote_host": "user@gpu-box",
"ssh_port": 2222,
}
)
)
assert result["exit_code"] == 0
assert len(posts) == 1
command = posts[0][1]["command"]
assert "ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no" in command
assert "-p 2222 user@gpu-box" in command
assert "tmux kill-session -t serve-abc123" in command
@pytest.mark.asyncio
async def test_cancel_download_rejects_invalid_remote_host_before_shell(monkeypatch):
posts = _install_httpx_client(monkeypatch)
result = await tools.do_cancel_download(
json.dumps({"session_id": "cookbook-abc123", "remote_host": "-bad"})
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_cancel_download_rejects_invalid_state_host_before_shell(monkeypatch):
posts = _install_httpx_client(
monkeypatch,
state={
"tasks": [
{
"sessionId": "cookbook-abc123",
"remoteHost": "-bad",
"sshPort": "22",
}
]
},
)
result = await tools.do_cancel_download(
json.dumps({"session_id": "cookbook-abc123"})
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_tail_serve_output_rejects_invalid_remote_host_before_shell(monkeypatch):
posts = _install_httpx_client(monkeypatch)
result = await tools.do_tail_serve_output(
json.dumps({"session_id": "serve-abc123", "remote_host": "-bad"})
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_tail_serve_output_rejects_invalid_state_host_before_shell(monkeypatch):
posts = _install_httpx_client(
monkeypatch,
state={
"tasks": [
{
"sessionId": "serve-abc123",
"remoteHost": "-bad",
"sshPort": "22",
}
]
},
)
result = await tools.do_tail_serve_output(
json.dumps({"session_id": "serve-abc123"})
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
@pytest.mark.asyncio
async def test_adopt_served_model_rejects_invalid_remote_host_before_shell(monkeypatch):
posts = _install_httpx_client(monkeypatch)
result = await tools.do_adopt_served_model(
json.dumps(
{
"tmux_session": "serve_abc123",
"model": "org/model",
"host": "-bad",
}
)
)
assert result["exit_code"] == 1
assert "Invalid remote_host" in result["error"]
assert posts == []
+27
View File
@@ -0,0 +1,27 @@
"""Guard the llama.cpp Docker pull recipe surfaced in Cookbook → Dependencies.
The upstream repo moved from github.com/ggerganov/llama.cpp to
github.com/ggml-org/llama.cpp. The old GHCR namespace
(ghcr.io/ggerganov/llama.cpp) no longer publishes images, so the
docker variant in the Dependencies panel returned
"failed to resolve reference … not found" when copied verbatim (#4457).
The other llama.cpp reference in routes/cookbook_routes.py already uses
ggml-org; this guards the JS recipe so the two stay aligned.
"""
from pathlib import Path
RECIPES_JS = (
Path(__file__).resolve().parent.parent / "static" / "js" / "cookbook-deps-recipes.js"
)
def test_llama_cpp_docker_recipe_uses_ggml_org_namespace():
source = RECIPES_JS.read_text(encoding="utf-8")
assert "ghcr.io/ggml-org/llama.cpp:server-cuda" in source, (
"Expected the llama.cpp docker recipe to pull from the ggml-org namespace."
)
assert "ghcr.io/ggerganov/llama.cpp" not in source, (
"The ggerganov GHCR namespace no longer publishes llama.cpp images. "
"Use ghcr.io/ggml-org/llama.cpp:server-cuda."
)
+55
View File
@@ -0,0 +1,55 @@
"""CPU architecture normalization for HW Fit hardware detection."""
import pytest
from services.hwfit import hardware
@pytest.fixture(autouse=True)
def _clear_hwfit_cache(monkeypatch):
hardware._cache_by_host.clear()
monkeypatch.setattr(hardware, "_remote_host", None)
monkeypatch.setattr(hardware, "_remote_platform", None)
monkeypatch.setattr(hardware, "_is_containerized", lambda: False)
yield
hardware._cache_by_host.clear()
def _stub_common_probe(monkeypatch, machine):
monkeypatch.setattr(hardware.platform, "machine", lambda: machine)
monkeypatch.setattr(hardware, "_get_ram_gb", lambda: 64.0)
monkeypatch.setattr(hardware, "_get_available_ram_gb", lambda: 48.0)
monkeypatch.setattr(hardware, "_get_cpu_count", lambda: 16)
monkeypatch.setattr(hardware, "_get_cpu_name", lambda: "Test CPU")
monkeypatch.setattr(hardware, "_detect_apple_silicon", lambda: None)
monkeypatch.setattr(hardware, "_detect_amd", lambda: None)
def test_detect_system_reports_cpu_arch_for_gpu_backends(monkeypatch):
"""GPU-backed systems still need CPU architecture for cpu_only estimates."""
_stub_common_probe(monkeypatch, "aarch64")
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: {
"gpu_name": "NVIDIA GB10",
"gpu_vram_gb": 64.0,
"gpu_count": 1,
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"backend": "cuda",
})
system = hardware.detect_system(fresh=True)
assert system["backend"] == "cuda"
assert system["cpu_arch"] == "arm64"
def test_detect_system_keeps_32_bit_arm_on_conservative_cpu_backend(monkeypatch):
"""Plain arm/armv7 is not the same as the ARM64-class cpu_arm fallback."""
_stub_common_probe(monkeypatch, "armv7l")
monkeypatch.setattr(hardware, "_detect_nvidia", lambda: None)
system = hardware.detect_system(fresh=True)
assert system["cpu_arch"] == "arm"
assert system["backend"] == "cpu_x86"
+25
View File
@@ -47,6 +47,12 @@ ARM64_SYSTEM = {
"gpu_vram_gb": 0, "gpu_vram_gb": 0,
} }
ARM32_SYSTEM = {
"backend": "arm",
"gpu_name": None,
"gpu_vram_gb": 0,
}
AARCH64_SYSTEM = { AARCH64_SYSTEM = {
"backend": "aarch64", "backend": "aarch64",
"gpu_name": None, "gpu_name": None,
@@ -79,6 +85,16 @@ def test_cpu_only_on_metal_apple_silicon_uses_cpu_arm_fallback():
assert metal_tps > 0 assert metal_tps > 0
def test_cpu_only_on_gpu_backend_uses_detected_arm64_cpu_arch():
"""A GPU backend on an ARM64 host should use the ARM CPU fallback for cpu_only."""
cuda_arm64 = dict(CUDA_SYSTEM, cpu_arch="aarch64", cpu_name="Ampere Altra")
cuda_arm64_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", cuda_arm64)
arm_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", CPU_ARM_SYSTEM)
assert cuda_arm64_tps == pytest.approx(arm_tps, rel=1e-9, abs=1e-9)
assert cuda_arm64_tps > 0
@pytest.mark.parametrize( @pytest.mark.parametrize(
"arm_alias_system", "arm_alias_system",
[ARM64_SYSTEM, AARCH64_SYSTEM, CPU_ARM_SYSTEM], [ARM64_SYSTEM, AARCH64_SYSTEM, CPU_ARM_SYSTEM],
@@ -93,6 +109,15 @@ def test_cpu_only_preserves_arm_backends(arm_alias_system):
assert alias_tps > 0 assert alias_tps > 0
def test_cpu_only_does_not_treat_plain_arm_as_arm64_fallback():
"""Docker/OCI plain arm is not the ARM64-class fallback used for Apple Silicon."""
arm32_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", ARM32_SYSTEM)
x86_tps = _estimate_speed(DENSE_MODEL, QUANT, "cpu_only", CPU_X86_SYSTEM)
assert arm32_tps == pytest.approx(x86_tps, rel=1e-9, abs=1e-9)
assert arm32_tps > 0
def test_cpu_only_preserves_known_cpu_backends(): def test_cpu_only_preserves_known_cpu_backends():
"""Known CPU backends should be preserved, not rewritten to cpu_x86.""" """Known CPU backends should be preserved, not rewritten to cpu_x86."""
for system in (CPU_X86_SYSTEM, CPU_ARM_SYSTEM): for system in (CPU_X86_SYSTEM, CPU_ARM_SYSTEM):
+9
View File
@@ -165,6 +165,15 @@ def test_intel_mac_skipped(monkeypatch):
assert hardware._detect_apple_silicon() is None assert hardware._detect_apple_silicon() is None
def test_plain_arm_mac_skipped(monkeypatch):
"""Only ARM64-class Macs should enter the Apple Silicon Metal path."""
monkeypatch.setattr(hardware, "_remote_host", None)
monkeypatch.setattr(hardware.platform, "system", lambda: "Darwin")
monkeypatch.setattr(hardware.platform, "machine", lambda: "armv7l")
monkeypatch.setattr(hardware, "_run", _fake_sysctl())
assert hardware._detect_apple_silicon() is None
def test_detect_system_propagates_unified_memory(monkeypatch): def test_detect_system_propagates_unified_memory(monkeypatch):
"""The unified_memory flag set by GPU detection must survive into the """The unified_memory flag set by GPU detection must survive into the
system dict so the API and UI can report it (it was being dropped).""" system dict so the API and UI can report it (it was being dropped)."""
+30
View File
@@ -206,3 +206,33 @@ def test_harmony_analysis_channel_routes_to_thinking(monkeypatch):
assert answer == "Here are the files." assert answer == "Here are the files."
assert "<|channel|>" not in thinking + answer assert "<|channel|>" not in thinking + answer
assert "<|message|>" not in thinking + answer assert "<|message|>" not in thinking + answer
def test_harmony_commentary_channel_no_marker_or_toolarg_leak(monkeypatch):
# gpt-oss commentary channel (tool-call preambles / function-arg bodies) is
# internal — it must not leak the channel marker, the `to=functions.*`
# recipient, or its body into the visible answer. The `<|channel|>comm` /
# `entary` split also exercises the suffix-hold for the new marker.
deltas = _run_stream(
"gpt-oss:20b",
[
'data: {"choices":[{"delta":{"content":"<|channel|>comm"}}]}',
'data: {"choices":[{"delta":{"content":"entary to=functions.web_search<|message|>Let me search the web."}}]}',
'data: {"choices":[{"delta":{"content":"<|end|><|channel|>final<|message|>Here are the "}}]}',
'data: {"choices":[{"delta":{"content":"results.<|end|>"}}]}',
"data: [DONE]",
],
monkeypatch,
)
thinking = "".join(d["delta"] for d in deltas if d.get("thinking"))
answer = "".join(d["delta"] for d in deltas if not d.get("thinking"))
# final channel is the only user-facing text
assert answer == "Here are the results."
# commentary body routed to thinking, not the visible answer
assert thinking == "Let me search the web."
# no harmony markers, channel name, or tool recipient leak anywhere
assert "<|channel|>" not in thinking + answer
assert "<|message|>" not in thinking + answer
assert "commentary" not in answer
assert "to=functions.web_search" not in thinking + answer
+1 -1
View File
@@ -105,7 +105,7 @@ def _patch_prefs(monkeypatch, data_dir):
"skills_enabled": True, "skills_enabled": True,
"auto_approve_skills": True, "auto_approve_skills": True,
} }
sys.modules["routes.prefs_routes"] = fake_prefs monkeypatch.setitem(sys.modules, "routes.prefs_routes", fake_prefs)
# Bust the base-prompt cache so our test re-reads the skill index. # Bust the base-prompt cache so our test re-reads the skill index.
from src import agent_loop from src import agent_loop