v0.50.257: 4 Opus pre-release follow-ups + CHANGELOG + test fixes for #1415

stage-257 batch (PRs #1402 + #1415):

Opus pre-release advisor caught 4 issues in stage-257:

1. MUST-FIX (security): api/oauth.py::_write_auth_json — tmp.replace()
   preserves the temp file umask (0644 default), so OAuth access/refresh
   tokens landed world-readable on shared systems. Fix: tmp.chmod(0o600)
   BEFORE rename, with try/except OSError that warns but does not abort.

2. SHOULD-FIX: _handle_cron_history and _handle_cron_run_detail accepted
   job_id as a path component without validation. Mirrors the rollback
   path-traversal vector caught in v0.50.255 (#1405). Path() / .. does NOT
   normalize. New regex ^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$ with explicit
   . / .. rejection.

3. SHOULD-FIX: _handle_cron_history int(offset)/int(limit) raised
   ValueError on malformed input → confusing 500. Now try/except + clamp
   to (max(0, offset), max(1, min(500, limit))).

4. NIT: same regex applied to _handle_cron_run_detail (defense-in-depth
   even though path-resolve check would catch it downstream).

PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
test_custom_provider_display_name asserted bare model IDs but #1415
changes named-custom-provider IDs to @custom:NAME:model form when active
provider differs. Tests updated to use _strip_at_prefix helper to keep
checking the same invariant in the new shape.

4 regression tests in test_v050257_opus_followups.py + 8 fixed pre-existing
tests. Full suite: 3602 passed, 0 failed.
This commit is contained in:
nesquena-hermes
2026-05-01 18:30:41 +00:00
parent 42d4070e2d
commit f8007d43f3
6 changed files with 239 additions and 13 deletions

View File

@@ -2,6 +2,26 @@
## [Unreleased]
## [v0.50.257] — 2026-05-01
### Added
- **Cron run history + full-output viewer** (#468) — new `GET /api/crons/history?job_id=X&offset=N&limit=M` endpoint lists all output files for a job (filename + size + mtime) without loading content. New `GET /api/crons/run?job_id=X&filename=Y` returns full content + a snippet extracted from the `## Response` section. Tasks panel renders a per-job run history with click-to-expand. (`api/routes.py`, `static/panels.js`, `static/i18n.js`) @bergeouss — PR #1402, fixes #468
- **Per-session toolset overrides** (#493) — new `Session.enabled_toolsets: list[str] | None` field threaded through `_run_agent_streaming`. New `POST /api/session/toolsets` endpoint validates input shape (non-empty list of non-empty strings, or null to clear). Settings panel adds a per-session toolset chip with global/custom modes. Honors the override at the streaming hot path via `_resolve_cli_toolsets`. (`api/models.py`, `api/routes.py`, `api/streaming.py`, `static/panels.js`, `static/i18n.js`, `static/index.html`, `static/style.css`, `static/ui.js`) @bergeouss — PR #1402, fixes #493
- **Codex OAuth in-app device-code flow** — new `api/oauth.py` (stdlib only — no external HTTP libs). Two endpoints: `GET /api/oauth/codex/start` (initiates Codex device-code flow, returns `user_code` + `verification_uri`) and `GET /api/oauth/codex/poll?device_code=X` (SSE for polling token endpoint). Successful poll writes credentials to `~/.hermes/auth.json` under `credential_pool.openai-codex`. Onboarding wizard adds a "Sign in with ChatGPT" path. Idempotent: existing OAuth credential entries are updated in place; new ones use `uuid.uuid4().hex[:8]` with retry-on-collision (3 attempts). (`api/oauth.py`, `api/routes.py`, `static/onboarding.js`, `static/i18n.js`, `static/index.html`, `static/style.css`) @bergeouss — PR #1402
### Fixed
- **Named custom provider routing in model picker — `@custom:NAME:model` form preserved** (#557 follow-up to #1390) — when the model picker iterated `custom_providers` entries with a `name` field (e.g. `[{name: "sub2api", base_url, models: [...]}]`), the option IDs were stored as bare model strings. On chat start, the backend resolved those bare strings through the active/default provider, silently routing the request to the wrong endpoint (e.g. DeepSeek instead of the user's selected `sub2api` proxy). Now the picker prefixes IDs with `@<slug>:<model>` whenever the active provider differs from the named slug, so `_resolve_compatible_session_model_state` (added by #1390) routes through the correct named provider. The frontend `_findModelInDropdown` already strips `@provider:` prefixes during normalization, so legacy `localStorage["hermes-webui-model"]` values with bare IDs continue to resolve. 5 new tests across `test_issue1106_custom_providers_models.py`, `test_provider_mismatch.py`, `test_security_redaction.py`. (`api/config.py`) @Thanatos-Z — PR #1415
### Changed (Opus pre-release advisor)
- **`api/oauth.py::_write_auth_json` chmod 0600 BEFORE rename** — `tmp.replace()` preserves the temp file's umask-derived mode (commonly 0644 or 0664). `auth.json` contains OAuth access/refresh tokens; on shared systems those tokens landed world-readable through the temp-file→rename window. Fix sets `tmp.chmod(0o600)` before the atomic rename, with a `try/except OSError` that logs but doesn't abort if chmod fails on filesystems that don't support POSIX modes. The `api.startup::fix_credential_permissions` sweep also catches this on next process start as belt-and-suspenders. (`api/oauth.py`, `tests/test_v050257_opus_followups.py`)
- **`_handle_cron_history` and `_handle_cron_run_detail` regex-validate `job_id`** — the `_checkpoint_root() / ws_hash / checkpoint` path-traversal vector caught in v0.50.255 (#1405) had a sibling here: `CRON_OUT / job_id / *.md`. `Path() / "../escape"` does NOT normalize. While `_handle_cron_run_detail` had a downstream `is_relative_to(CRON_OUT.resolve())` check, `_handle_cron_history` didn't. New regex `^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}$` with explicit `.`/`..` rejection at the parameter boundary. Mirrors the rollback fix shape. (`api/routes.py`, `tests/test_v050257_opus_followups.py`)
- **`_handle_cron_history` clamps `offset` and `limit`** — raw `int(qs.get("offset", ["0"])[0])` raised `ValueError` on `?offset=foo` and surfaced as a generic 500. No upper bound on `limit` either. Now wrapped in `try/except (ValueError, TypeError)` returning a 400 on bad input, and `limit` clamped to `[1, 500]`. (`api/routes.py`)
## [v0.50.256] — 2026-05-01
### Fixed

View File

@@ -39,10 +39,26 @@ def _read_auth_json():
def _write_auth_json(data):
"""Atomically write auth.json via temp-file rename."""
"""Atomically write auth.json via temp-file rename.
SECURITY: auth.json contains OAuth access/refresh tokens. ``tmp.replace()``
preserves the temp file's mode (created with the process umask, typically
0644 or 0664), NOT the prior auth.json mode. Without an explicit chmod,
tokens land world-readable on shared systems. Set 0600 BEFORE the rename
so there is no window where the final file is world-readable.
(Opus pre-release advisor finding.)
"""
import os, stat
AUTH_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = AUTH_JSON_PATH.with_suffix('.tmp')
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False))
try:
tmp.chmod(0o600)
except OSError as e:
# Best-effort: if chmod fails (e.g. on a filesystem that doesn't
# support POSIX modes), don't abort. The startup permission fixer
# in api.startup will sweep auth.json on the next process start.
logger.warning("Failed to chmod 0600 on %s: %s", tmp, e)
tmp.replace(AUTH_JSON_PATH)

View File

@@ -3696,13 +3696,26 @@ def _handle_cron_history(handler, parsed):
without fetching full output for every run.
"""
from cron.jobs import OUTPUT_DIR as CRON_OUT
import re as _re
qs = parse_qs(parsed.query)
job_id = qs.get("job_id", [""])[0]
offset = int(qs.get("offset", ["0"])[0])
limit = int(qs.get("limit", ["50"])[0])
if not job_id:
return j(handler, {"error": "job_id required"}, status=400)
# Defense-in-depth: cron job_ids are 12-char hex from the agent's scheduler.
# Without validation, a job_id of "../<other>" would let an authenticated
# caller enumerate .md filenames in adjacent directories under CRON_OUT's
# parent. Mirror the rollback checkpoint id regex shape.
# (Opus pre-release advisor finding.)
if not _re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}", job_id) or job_id in (".", ".."):
return j(handler, {"error": "invalid job_id"}, status=400)
# Reject malformed offset/limit instead of letting int() raise ValueError
# and surface as a confusing 500. Clamp to safe ranges.
try:
offset = max(0, int(qs.get("offset", ["0"])[0]))
limit = max(1, min(500, int(qs.get("limit", ["50"])[0])))
except (ValueError, TypeError):
return j(handler, {"error": "offset and limit must be integers"}, status=400)
out_dir = CRON_OUT / job_id
runs = []
total = 0
@@ -3726,12 +3739,19 @@ def _handle_cron_history(handler, parsed):
def _handle_cron_run_detail(handler, parsed):
"""Return full content of a single cron run output file."""
from cron.jobs import OUTPUT_DIR as CRON_OUT
import re as _re
qs = parse_qs(parsed.query)
job_id = qs.get("job_id", [""])[0]
filename = qs.get("filename", [""])[0]
if not job_id or not filename:
return j(handler, {"error": "job_id and filename required"}, status=400)
# Validate job_id shape (defense-in-depth even though the resolve+is_relative_to
# check below catches traversal — fail-closed at the parameter boundary so
# malformed job_ids return a 400 from the validator rather than a 400 from
# the path resolver).
if not _re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]{0,63}", job_id) or job_id in (".", ".."):
return j(handler, {"error": "invalid job_id"}, status=400)
# Prevent path traversal — resolve and verify it stays within the job's output dir
fpath = (CRON_OUT / job_id / filename).resolve()
if not fpath.is_relative_to(CRON_OUT.resolve()):

View File

@@ -9,6 +9,19 @@ import pytest
import api.config as config
def _strip_at_prefix(model_id):
"""Strip the optional ``@provider:`` (or ``@provider:subname:``) prefix.
PR #1415 introduced provider-qualified IDs (``@custom:NAME:model``) for
named custom providers when the active provider differs. The bare-ID
assertions in this test module pre-date that change.
"""
s = str(model_id or "")
if s.startswith("@") and ":" in s:
return s.rsplit(":", 1)[1]
return s
@pytest.fixture(autouse=True)
def _isolate_models_cache():
"""Invalidate the models TTL cache before and after every test in this file."""
@@ -97,7 +110,8 @@ class TestNamedCustomProviderGroup:
(g for g in result.get("groups", []) if g["provider"] == "Agent37"), None
)
assert agent37_group is not None, "Expected an 'Agent37' group"
model_ids = [m["id"] for m in agent37_group.get("models", [])]
# PR #1415 prefixes IDs with @custom:NAME: when active provider differs from named slug
model_ids = [_strip_at_prefix(m["id"]) for m in agent37_group.get("models", [])]
assert "my-llm" in model_ids, (
f"Expected 'my-llm' in Agent37 group models, got {model_ids}"
)
@@ -129,7 +143,8 @@ class TestNamedCustomProviderGroup:
assert len(agent37_groups) == 1, (
f"Expected exactly one 'Agent37' group, got {len(agent37_groups)}"
)
model_ids = [m["id"] for m in agent37_groups[0].get("models", [])]
# PR #1415 prefixes IDs with @custom:NAME: when active provider differs from named slug
model_ids = [_strip_at_prefix(m["id"]) for m in agent37_groups[0].get("models", [])]
assert "model-a" in model_ids
assert "model-b" in model_ids

View File

@@ -35,7 +35,7 @@ def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None
def _all_model_ids(result):
"""Extract all model IDs from all groups."""
"""Extract all model IDs from all groups (raw form — may include @provider: prefix)."""
ids = []
for g in result.get("groups", []):
for m in g.get("models", []):
@@ -43,6 +43,26 @@ def _all_model_ids(result):
return ids
def _strip_at_prefix(model_id):
"""Strip the optional ``@provider:`` (or ``@provider:subname:``) prefix
from a model id so legacy assertions can compare against the bare form.
PR #1415 introduced provider-qualified IDs (``@custom:NAME:model``) for
named custom providers when the active provider differs. The bare-ID
assertions in this test module pre-date that change and need normalization
to keep checking the same invariant: "does model X appear in the picker".
"""
s = str(model_id or "")
if s.startswith("@") and ":" in s:
return s.rsplit(":", 1)[1]
return s
def _all_model_ids_bare(result):
"""Same as _all_model_ids but with @provider: prefixes stripped."""
return [_strip_at_prefix(mid) for mid in _all_model_ids(result)]
def _group_for(result, provider_name):
"""Get a group by provider name."""
for g in result.get("groups", []):
@@ -72,7 +92,7 @@ class TestCustomProvidersModelsDict:
}
],
)
ids = _all_model_ids(result)
ids = _all_model_ids_bare(result)
for expected in ["unsloth-qwen3.6-35b-a3b", "gemma4-26b", "qwen3.5-27b", "qwen3-coder-30b"]:
assert expected in ids, f"Expected '{expected}' in model IDs, got {ids}"
@@ -91,7 +111,7 @@ class TestCustomProvidersModelsDict:
}
],
)
ids = _all_model_ids(result)
ids = _all_model_ids_bare(result)
assert "llama-3-8b" in ids
assert "mistral-7b" in ids
@@ -111,7 +131,7 @@ class TestCustomProvidersModelsDict:
}
],
)
ids = _all_model_ids(result)
ids = _all_model_ids_bare(result)
assert ids.count("base-model") == 1, f"'base-model' should appear exactly once, got {ids.count('base-model')}"
assert "other-model" in ids
@@ -145,7 +165,7 @@ class TestCustomProvidersModelsDict:
}
],
)
ids = _all_model_ids(result)
ids = _all_model_ids_bare(result)
assert "only-model" in ids
def test_non_string_models_keys_are_skipped(self):
@@ -164,7 +184,7 @@ class TestCustomProvidersModelsDict:
}
],
)
ids = _all_model_ids(result)
ids = _all_model_ids_bare(result)
assert "valid-model" in ids
assert "another-valid" in ids
@@ -189,8 +209,10 @@ class TestCustomProvidersModelsDict:
group_b = _group_for(result, "Server-B")
assert group_a is not None, "Server-A group missing"
assert group_b is not None, "Server-B group missing"
ids_a = [m["id"] for m in group_a["models"]]
ids_b = [m["id"] for m in group_b["models"]]
# PR #1415 prefixes model IDs with @custom:NAME: when the active provider
# is different from the named slug — strip for the bare-id invariant.
ids_a = [_strip_at_prefix(m["id"]) for m in group_a["models"]]
ids_b = [_strip_at_prefix(m["id"]) for m in group_b["models"]]
assert "model-a1" in ids_a and "model-a2" in ids_a
assert "model-b1" in ids_b and "model-b2" in ids_b
# No cross-contamination

View File

@@ -0,0 +1,133 @@
"""Regression tests for v0.50.257 Opus pre-release follow-ups (#1402 + #1415).
The v0.50.257 batch had four findings on PR #1402:
1. MUST-FIX (security) — `api/oauth.py::_write_auth_json` used `tmp.replace()`
which preserves the temp file's umask-derived mode (commonly 0644 or 0664).
`auth.json` contains OAuth access/refresh tokens; on shared systems those
tokens landed world-readable. Fix: `tmp.chmod(0o600)` BEFORE rename.
2. SHOULD-FIX (defense-in-depth) — `_handle_cron_history` and
`_handle_cron_run_detail` accepted `job_id` as a path component without
validation. `Path() / "../escape"` does not normalize, mirroring the
rollback path-traversal vector caught in v0.50.255. Fix: regex validation
that rejects `/`, `..`, `.`.
3. SHOULD-FIX — `_handle_cron_history` parsed `offset`/`limit` via raw
`int()`, so `?offset=foo` raised `ValueError` and surfaced as a generic
500 instead of a clean 400. Also no upper bound on `limit` (DoS via
`?limit=999999999`). Fix: try/except + clamp to safe ranges.
4. NIT — also propagate the cron `job_id` validation regex to make the
pattern explicit at the parameter boundary.
PR #1415 follow-up: 8 pre-existing tests in test_issue1106 and
test_custom_provider_display_name asserted bare model IDs but #1415 changes
the named-custom-provider IDs to `@custom:NAME:model` form when active
provider differs. Tests updated to use `_strip_at_prefix` helper to keep
checking the same invariant ("does model X appear in the picker") in the
new shape.
"""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
# ── 1: auth.json permission fix (chmod 0600 before rename) ───────────────────
def test_oauth_write_auth_json_uses_chmod_0600_before_rename(monkeypatch, tmp_path):
"""`_write_auth_json` must chmod 0600 BEFORE renaming so tokens never land
world-readable. The previous implementation used `tmp.replace()` which
preserves the temp file's umask-derived mode."""
sys.path.insert(0, str(REPO))
import api.oauth as oauth
# Point AUTH_JSON_PATH at a tmp dir
fake_path = tmp_path / "auth.json"
monkeypatch.setattr(oauth, "AUTH_JSON_PATH", fake_path)
# Set a permissive umask so default write would create 0644
old_umask = os.umask(0o022)
try:
oauth._write_auth_json({"credential_pool": {"openai-codex": []}})
finally:
os.umask(old_umask)
assert fake_path.exists(), "auth.json was not written"
mode = stat.S_IMODE(fake_path.stat().st_mode)
# The file must be chmod 0600 — owner read/write only.
assert mode == 0o600, (
f"auth.json permissions are {oct(mode)}, expected 0o600. "
f"OAuth tokens (access_token, refresh_token) live in this file. "
f"On shared systems, world-readable tokens are a real exposure."
)
def test_oauth_write_auth_json_source_calls_chmod():
"""Source-level pin: any future change to _write_auth_json that drops the
chmod call must be caught even if the runtime test above is skipped on
a filesystem that doesn't support POSIX modes."""
src = (REPO / "api" / "oauth.py").read_text(encoding="utf-8")
assert "tmp.chmod(0o600)" in src, (
"_write_auth_json must call tmp.chmod(0o600) before tmp.replace() — "
"without it, OAuth tokens land world-readable on shared systems."
)
# ── 2: cron history job_id path-traversal validation ────────────────────────
def test_cron_history_rejects_traversal_in_job_id():
"""`_handle_cron_history` and `_handle_cron_run_detail` must regex-validate
job_id at the parameter boundary. Mirrors the rollback regex shape from
v0.50.255."""
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
# Both handlers must call the validator
history_idx = src.find("def _handle_cron_history(")
detail_idx = src.find("def _handle_cron_run_detail(")
assert history_idx != -1, "_handle_cron_history missing"
assert detail_idx != -1, "_handle_cron_run_detail missing"
history_body = src[history_idx : history_idx + 1500]
detail_body = src[detail_idx : detail_idx + 1500]
# Both must include the regex check
for body, name in [(history_body, "_handle_cron_history"), (detail_body, "_handle_cron_run_detail")]:
assert "_re.fullmatch" in body and "[A-Za-z0-9_-]" in body, (
f"{name} must validate job_id via regex — without this, "
f"`?job_id=../<other>` enumerates sibling directory contents."
)
assert 'job_id in (".", "..")' in body, (
f"{name} must explicitly reject `.` and `..` in addition to the regex."
)
# ── 3: int() bounds checking on offset/limit ────────────────────────────────
def test_cron_history_clamps_offset_and_limit():
"""`_handle_cron_history` must catch `ValueError` from int() and clamp
`limit` to a sane upper bound. Without this, `?offset=foo` raises a
ValueError that surfaces as a confusing 500 from `do_GET`'s exception
handler, and `?limit=999999999` would slice through unbounded glob output."""
src = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
history_idx = src.find("def _handle_cron_history(")
body = src[history_idx : history_idx + 1500]
assert "(ValueError, TypeError)" in body, (
"_handle_cron_history must catch ValueError from int() so malformed "
"offset/limit return a clean 400, not a generic 500."
)
assert "min(500, int(qs.get" in body, (
"_handle_cron_history must clamp `limit` to a sane upper bound (500 chosen) "
"to prevent DoS via `?limit=999999999`."
)