Merge pull request #1652 from nesquena/stage-296
Release v0.50.296 — 3-PR batch (TPS in headers + session save mode + Codex OAuth onboarding)
No files matched your search
@@ -1,5 +1,41 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.296] — 2026-05-04
|
||||
|
||||
### Fixed (3 PRs — closes #1406, #1617; refs #1362)
|
||||
|
||||
- **Per-turn TPS now visible in assistant message headers (default-off, opt-in via Preferences)** (#1640 by @Michaelyklam, closes #1617) — UX gate **APPROVED by @aronprins** with default-off + opt-in setting addition. Previously `_turnTps` calculation existed in `api/streaming.py` but was rendered into a global titlebar `tpsStat` element that's been hidden by default since v0.50.x. New `show_tps` boolean setting in Preferences (default `false`) renders an inline `.msg-tps-inline` chip in each assistant message header when enabled. Useful for power users tuning local-model setups (LM Studio, Ollama, llama.cpp, vLLM) where TPS varies turn-to-turn based on context length, parallel slots, and prompt complexity. **Backend changes:** `api/metering.py` adds explicit `tps_available` field (boolean — strict, requires both real exact token count AND backend-measured turn duration), drops placeholder `0.0` TPS when no real reading exists, switches live counting from character-count-derived text length to streaming-callback deltas. Final `_turnTps` computed from exact final output token usage divided by backend-measured turn duration when both available, persisted on assistant message and sent in `done` payload only when both signals available. **Hot-apply:** Preferences autosave updates `window._showTps` global, clears the message render cache, and re-renders messages — toggling the setting reflects in open tabs without refresh. UI evidence under `docs/pr-media/1640/` showing default-off transcript, hot-apply with TPS visible, and the Settings → Preferences toggle.
|
||||
|
||||
- **Operator-level config knob for first-turn session save timing** (#1648 by @Michaelyklam, closes #1406) — operators wanting crash-resilience for the user's first prompt (vs accepting the first prompt being in-memory-only until streaming begins) now have a `webui.session_save_mode` config.yaml knob with values `deferred` (default — preserves the v0.50.230 fix for #1171 orphan-Untitled files) and `eager`. **Eager mode** materializes the user message into `s.messages` before launching the agent thread, plus updates `_apply_core_sync_or_error_marker` (WAL/repair path) and the streaming-thread context-build path (`_drop_checkpointed_current_user_from_context`) to avoid double-counting the user turn. Implementation matches @nesquena-hermes's prescribed shape from #1406's maintainer comment 1:1 — no Settings UI toggle (operator-level only), default stays deferred (orphan-Untitled hygiene preserved), threshold is "≥1 user message" not "did `new_session()` get called" (so empty-new-chat-then-switch-away doesn't recreate the orphan-file class). Validated `_WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"}`; unknown values fail closed to `deferred`. 132-LOC test file covering both modes + WAL/repair interaction + duplicate-context filtering.
|
||||
|
||||
- **In-app OAuth onboarding flow for OpenAI Codex** (#1650 by @Michaelyklam, refs #1362) — three new endpoints: `POST /api/onboarding/oauth/start` (initiates the device-code flow), `GET /api/onboarding/oauth/poll?flow_id=...` (returns high-level status: `pending|success|expired|cancelled|error`), `POST /api/onboarding/oauth/cancel` (aborts an in-flight flow). **Server-owned lifecycle:** all sensitive provider state (device_auth_id, code_verifier, authorization_code, access_token, refresh_token, token_data) lives in a process-local `_OAUTH_FLOWS` dict keyed by an opaque WebUI-local `flow_id` (UUID4). Browser only sees `flow_id`, `user_code`, `verification_uri`, status — never raw OAuth lifecycle secrets. 15-minute flow timeout. **Token persistence:** successful Codex credentials write to the **active profile's** `auth.json` `credential_pool.openai-codex` (atomic tmp+rename, chmod 0o600 on tmp BEFORE rename so final file never has world-readable window, defense-in-depth post-rename chmod). Allowlist `_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"}`; explicit blocklist for anthropic/claude/nous/qwen/gemini/minimax/copilot (rejected with generic "Only OpenAI Codex OAuth is supported in WebUI onboarding right now" — no internal triage state leaked). Implementation matches @nesquena-hermes's prescribed shape from #1362's maintainer comment 1:1 (server-owned state machine, no client-side device codes, abort endpoint, profile-scoped storage, opt-in). Updated `static/onboarding.js` for the `openai-codex` OAuth-pending path with clickable verification URL, prominent user code with copy-to-clipboard, abort button. Updated Codex auth endpoints to current Hermes Agent Codex protocol: `https://auth.openai.com/api/accounts/deviceauth/usercode`, `.../api/accounts/deviceauth/token`, `.../oauth/token`. 182-LOC test file covering route shape, secret-leak prevention, allowlist, expiration, cancellation, profile-scoped credential write, frontend endpoint usage, and the unsupported-provider note copy update. **First step on the #1362 sprint roadmap** — Anthropic Claude OAuth is the planned v2.
|
||||
|
||||
### Tests
|
||||
|
||||
4255 → **4284 passing** (+29 regression tests across `tests/test_issue1617_tps_message_header.py` (31), `tests/test_session_save_mode.py` (~13 new + edits), `tests/test_issue1362_codex_oauth_onboarding.py` (9), plus existing test updates for context-window-persistence, preferences-autosave). 0 regressions. Full suite ~120s.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- **Opus advisor on stage-296 combined diff: SHIP verdict.** All 14 verification questions cleared, with focused OAuth security audit on #1650 (in-memory flow lifecycle correct, lock not held during network IO, no flow_id leakage path, allowlist fail-closed, chmod-before-rename correctly implemented per the prior security-fix pattern, sensitive fields scrubbed on every terminal status transition, no internal triage state in error messages). Two minor follow-ups absorbed in-release per <20-LOC defensive policy:
|
||||
- `_get_active_hermes_home()` exception fallback now logs a `logger.warning(...)` so silent profile-corruption fallback is observable in logs.
|
||||
- Codex credential pool find-loop now accepts both `source == "manual:device_code"` (current code) AND `source == "oauth_device"` (legacy from prior Codex OAuth implementations) so users with prior creds get their entry updated in-place rather than accumulating a stale duplicate pool entry.
|
||||
- **#1640 has @aronprins UX-gate APPROVED** (May 04 19:24 UTC) after a tighten request landed (default-off setting + Settings → Preferences toggle, hot-applied without refresh).
|
||||
- **#1648 implements @nesquena-hermes's prescribed shape** from the #1406 maintainer comment 1:1.
|
||||
- **#1650 implements @nesquena-hermes's prescribed shape** from the #1362 maintainer comment 1:1, with explicit security-audit alignment (server-owned device codes, opaque flow_id, profile-scoped storage, blocklist for known-OAuth providers awaiting v2).
|
||||
- **JS syntax**: 5 modified `.js` files (`boot.js`, `messages.js`, `onboarding.js`, `panels.js`, `ui.js`) clean.
|
||||
- **Browser API sanity**: 11/11 endpoints OK on stage server.
|
||||
- **Conflict resolution**: clean auto-merge across all 3 PRs (rebased #1640 onto current master from 10-commits-behind base; #1648 + #1650 already on current master; no overlapping code regions across the 3 PRs in `api/streaming.py`, `api/routes.py`, or `static/`).
|
||||
|
||||
### Authors
|
||||
|
||||
- @Michaelyklam — 3 PRs (#1640, #1648, #1650)
|
||||
|
||||
@Michaelyklam continues the strong contribution pattern from #1597, #1598, #1600, #1601, #1621, #1637 — this is now 9 merged PRs across the v0.50.292-296 release window.
|
||||
|
||||
### Trust boundary note
|
||||
|
||||
This release ships the first user-facing OAuth flow in the WebUI. Token storage path, atomic write semantics, chmod timing, server-side flow state, and the allowlist/blocklist pattern are all in scope for security reviewers reviewing v0.50.296. The Hermes Agent CLI's `auth.json` format is the source-of-truth contract — both the WebUI and CLI write the same `credential_pool.openai-codex` shape, so credentials added via either surface are usable by either surface.
|
||||
|
||||
## [v0.50.295] — 2026-05-04
|
||||
|
||||
### Fixed (3 PRs — closes #1360, #1451, #1463, #1618, #1619)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.50.295 (May 04, 2026) — 4255 tests collected
|
||||
> Last updated: v0.50.296 (May 04, 2026) — 4284 tests collected
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.50.295, May 04, 2026*
|
||||
*Total automated tests collected: 4255*
|
||||
*Last updated: v0.50.296, May 04, 2026*
|
||||
*Total automated tests collected: 4284*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
@@ -199,6 +199,10 @@ def _get_config_path() -> Path:
|
||||
return HOME / ".hermes" / "config.yaml"
|
||||
|
||||
|
||||
_WEBUI_SESSION_SAVE_MODES = {"deferred", "eager"}
|
||||
_DEFAULT_WEBUI_SESSION_SAVE_MODE = "deferred"
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
"""Return the cached config dict, loading from disk if needed."""
|
||||
if not _cfg_cache:
|
||||
@@ -206,6 +210,28 @@ def get_config() -> dict:
|
||||
return _cfg_cache
|
||||
|
||||
|
||||
def get_webui_session_save_mode(config_data: dict | None = None) -> str:
|
||||
"""Return the validated first-turn session persistence mode.
|
||||
|
||||
``deferred`` preserves the current first-turn sidecar behaviour: persist
|
||||
pending_user_message/runtime fields before streaming, then merge the turn
|
||||
after the agent finishes. ``eager`` additionally checkpoints the current
|
||||
user turn into ``messages`` before launching the agent thread. Unknown
|
||||
values fail closed to ``deferred`` so a typo never reintroduces eager disk
|
||||
writes unexpectedly.
|
||||
"""
|
||||
active_cfg = config_data if isinstance(config_data, dict) else cfg
|
||||
webui_cfg = active_cfg.get("webui", {}) if isinstance(active_cfg, dict) else {}
|
||||
if not isinstance(webui_cfg, dict):
|
||||
return _DEFAULT_WEBUI_SESSION_SAVE_MODE
|
||||
mode = webui_cfg.get("session_save_mode", _DEFAULT_WEBUI_SESSION_SAVE_MODE)
|
||||
if isinstance(mode, str):
|
||||
normalized = mode.strip().lower()
|
||||
if normalized in _WEBUI_SESSION_SAVE_MODES:
|
||||
return normalized
|
||||
return _DEFAULT_WEBUI_SESSION_SAVE_MODE
|
||||
|
||||
|
||||
def reload_config() -> None:
|
||||
"""Reload config.yaml from the active profile's directory."""
|
||||
global _cfg_mtime
|
||||
@@ -3082,6 +3108,7 @@ _SETTINGS_DEFAULTS = {
|
||||
"onboarding_completed": False,
|
||||
"send_key": "enter", # 'enter' or 'ctrl+enter'
|
||||
"show_token_usage": False, # show input/output token badge below assistant messages
|
||||
"show_tps": False, # show tokens-per-second chip in assistant message headers
|
||||
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
|
||||
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
|
||||
"check_for_updates": True, # check if webui/agent repos are behind upstream
|
||||
@@ -3207,6 +3234,7 @@ _SETTINGS_ENUM_VALUES = {
|
||||
_SETTINGS_BOOL_KEYS = {
|
||||
"onboarding_completed",
|
||||
"show_token_usage",
|
||||
"show_tps",
|
||||
"show_cli_sessions",
|
||||
"sync_to_insights",
|
||||
"check_for_updates",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"""
|
||||
Hermes Web UI -- Streaming performance metering.
|
||||
|
||||
Tracks Tokens Per Second (TPS) across all active WebUI sessions, and the
|
||||
HIGH/LOW TPS values observed over the past 60 minutes. Metering data is
|
||||
emitted via SSE events so the header label can update live during a stream.
|
||||
Tracks Tokens Per Second (TPS) across active WebUI streams. Metering data is
|
||||
emitted via SSE events so a streaming assistant message can update its own
|
||||
header while the turn is running.
|
||||
|
||||
Architecture
|
||||
────────────
|
||||
Each streaming session is tracked independently. TPS per session is:
|
||||
Each streaming session is tracked independently. TPS per stream is:
|
||||
|
||||
session_tps = total_tokens / (last_token_ts - first_token_ts)
|
||||
stream_tps = total_stream_deltas / (last_delta_ts - first_delta_ts)
|
||||
|
||||
The global tps is the average of all currently active sessions' TPS values.
|
||||
The global tps is the average of all currently active streams' TPS values.
|
||||
This correctly represents the system's real-time capacity regardless of how
|
||||
many sessions are running or how long each has been streaming.
|
||||
|
||||
@@ -19,8 +19,8 @@ For HIGH/LOW tracking, every stats snapshot records the current global tps
|
||||
(only when > 0 — idle periods are skipped) into a rolling 60-minute history.
|
||||
The max/min of that history gives the peak throughput observed over the past hour.
|
||||
|
||||
The ticker in streaming.py calls get_interval() — it returns 1.0 when sessions
|
||||
are actively receiving tokens so the header updates at 1 Hz, and 10.0 when idle
|
||||
The ticker in streaming.py calls get_interval() — it returns 1.0 when streams
|
||||
are actively receiving output deltas so message headers update at 1 Hz, and 10.0 when idle
|
||||
so the ticker exits and no idle readings are emitted.
|
||||
|
||||
Usage from api/streaming.py
|
||||
@@ -28,15 +28,17 @@ Usage from api/streaming.py
|
||||
from api.metering import meter
|
||||
|
||||
meter().begin_session(stream_id) # stream starts
|
||||
meter().record_token(stream_id, running_output) # per output token
|
||||
meter().record_reasoning(stream_id, running_reasoning_len) # per reasoning token
|
||||
meter().record_token(stream_id, running_output_deltas)
|
||||
meter().record_reasoning(stream_id, running_reasoning_deltas)
|
||||
|
||||
The SSE `metering` event payload:
|
||||
{
|
||||
"tps": 47.3, # average TPS across active sessions (real-time)
|
||||
"high": 52.1, # highest average TPS observed in the past 60 minutes
|
||||
"low": 31.4, # lowest average TPS (excl. readings < 1 tps, to ignore idle)
|
||||
"active": 1, # sessions currently streaming
|
||||
"tps": 47.3, # omitted/null until a real reading exists
|
||||
"tps_available": true, # frontend must hide TPS when false
|
||||
"estimated": false, # never show byte/character-size estimates
|
||||
"high": 52.1,
|
||||
"low": 31.4,
|
||||
"active": 1,
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -60,9 +62,9 @@ class _SessionMeter:
|
||||
def total_tokens(self) -> int:
|
||||
return self.output_tokens + self.reasoning_tokens
|
||||
|
||||
def tps(self) -> float:
|
||||
def tps(self) -> float | None:
|
||||
if self.first_token_ts == 0.0 or self.last_token_ts <= self.first_token_ts:
|
||||
return 0.0
|
||||
return None
|
||||
return self.total_tokens() / (self.last_token_ts - self.first_token_ts)
|
||||
|
||||
|
||||
@@ -148,12 +150,15 @@ class GlobalMeter:
|
||||
if not self._sessions:
|
||||
self._window_start = now
|
||||
|
||||
# Compute global tps: average of per-session TPS values
|
||||
# Compute global tps: average only streams with a real reading. The
|
||||
# UI hides TPS entirely when this is unavailable instead of showing
|
||||
# placeholder/estimated values.
|
||||
active = [s for s in self._sessions.values() if s.first_token_ts > 0]
|
||||
if active:
|
||||
global_tps = sum(s.tps() for s in active) / len(active)
|
||||
active_tps = [v for s in active for v in [s.tps()] if v is not None and v > 0]
|
||||
if active_tps:
|
||||
global_tps = sum(active_tps) / len(active_tps)
|
||||
else:
|
||||
global_tps = 0.0
|
||||
global_tps = None
|
||||
|
||||
# Prune readings older than 1 hour
|
||||
cutoff = now - _HOUR_SECS
|
||||
@@ -162,7 +167,7 @@ class GlobalMeter:
|
||||
# Only record this snapshot for HIGH/LOW if there is active work.
|
||||
# This prevents idle periods from flooding the history and keeps
|
||||
# HIGH/LOW meaningful for the past hour of actual throughput.
|
||||
if global_tps > 0:
|
||||
if global_tps is not None and global_tps > 0:
|
||||
self._readings.append((now, global_tps))
|
||||
|
||||
# HIGH/LOW from the past hour (skip near-zero idle readings)
|
||||
@@ -171,9 +176,11 @@ class GlobalMeter:
|
||||
low = min(active_readings) if active_readings else 0.0
|
||||
|
||||
return {
|
||||
'tps': round(global_tps, 1),
|
||||
'high': round(high, 1),
|
||||
'low': round(low, 1),
|
||||
'tps': round(global_tps, 1) if global_tps is not None else None,
|
||||
'tps_available': global_tps is not None,
|
||||
'estimated': False,
|
||||
'high': round(high, 1) if high else None,
|
||||
'low': round(low, 1) if low else None,
|
||||
'active': len(self._sessions),
|
||||
}
|
||||
|
||||
|
||||
@@ -629,18 +629,26 @@ def _apply_core_sync_or_error_marker(
|
||||
# prompt submitted just before a server restart, so materialize it before
|
||||
# clearing runtime stream state.
|
||||
if len(session.messages) != 0:
|
||||
_pending_text = " ".join(str(session.pending_user_message or "").split())
|
||||
_already_checkpointed = False
|
||||
if _pending_text and session.messages:
|
||||
_last_msg = session.messages[-1]
|
||||
if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
|
||||
_last_text = " ".join(str(_last_msg.get('content') or "").split())
|
||||
_already_checkpointed = _last_text == _pending_text
|
||||
_recovered_ts = int(time.time())
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
recovered = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
if not _already_checkpointed:
|
||||
recovered = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
|
||||
@@ -1,196 +1,442 @@
|
||||
"""In-app OAuth flow implementations for providers like OpenAI Codex.
|
||||
"""In-app OAuth flow implementations for onboarding.
|
||||
|
||||
Uses only stdlib (urllib.request, json, time) — no external dependencies.
|
||||
Credentials are stored in ~/.hermes/auth.json under the credential_pool.
|
||||
The browser receives only WebUI-local flow metadata (flow_id, user_code,
|
||||
verification_uri, high-level status). Provider device/auth codes and OAuth
|
||||
tokens stay server-side and are persisted to the active Hermes profile's
|
||||
``auth.json`` credential_pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Compatibility for older helper tests and self-heal code that import these.
|
||||
AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
|
||||
|
||||
# ── Codex OAuth constants (from hermes_cli/auth.py) ──
|
||||
CODEX_CLIENT_ID = "pdlLIX2Y72MIl2rhLhTE9VV9bN905kBh"
|
||||
CODEX_AUTH_URL = "https://auth.openai.com/oauth/device/authorize"
|
||||
CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
||||
CODEX_SCOPE = "openid profile email offline_access"
|
||||
CODEX_GRANT_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
CODEX_ISSUER = "https://auth.openai.com"
|
||||
CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
CODEX_VERIFICATION_URI = f"{CODEX_ISSUER}/codex/device"
|
||||
CODEX_USER_CODE_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/usercode"
|
||||
CODEX_DEVICE_TOKEN_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/token"
|
||||
CODEX_TOKEN_URL = f"{CODEX_ISSUER}/oauth/token"
|
||||
CODEX_REDIRECT_URI = f"{CODEX_ISSUER}/deviceauth/callback"
|
||||
CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
CODEX_FLOW_MAX_WAIT_SECONDS = 15 * 60
|
||||
|
||||
_ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex"}
|
||||
_REJECTED_ONBOARDING_OAUTH_PROVIDERS = {
|
||||
"anthropic",
|
||||
"claude",
|
||||
"claude-code",
|
||||
"nous",
|
||||
"qwen-oauth",
|
||||
"gemini-cli",
|
||||
"google-gemini-cli",
|
||||
"minimax",
|
||||
"minimax-oauth",
|
||||
"copilot",
|
||||
"copilot-acp",
|
||||
}
|
||||
|
||||
_OAUTH_FLOWS: dict[str, dict[str, Any]] = {}
|
||||
_OAUTH_FLOWS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
# ── auth.json helpers ──
|
||||
def _get_active_hermes_home() -> Path:
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
|
||||
def _read_auth_json():
|
||||
"""Read auth.json and return parsed dict, or empty dict."""
|
||||
if AUTH_JSON_PATH.exists():
|
||||
return Path(get_active_hermes_home())
|
||||
except Exception as exc:
|
||||
# Per Opus advisor on stage-296: log the silent fallback so a corrupt
|
||||
# profile state ending up writing tokens to ~/.hermes (instead of the
|
||||
# active profile) is observable in logs rather than failing silently.
|
||||
logger.warning(
|
||||
"Falling back to ~/.hermes for OAuth credential storage: "
|
||||
"active-profile resolution failed: %s",
|
||||
exc,
|
||||
)
|
||||
return Path.home() / ".hermes"
|
||||
|
||||
|
||||
# ── legacy auth.json helpers ────────────────────────────────────────────────
|
||||
|
||||
def _read_auth_json(auth_path: Path | None = None) -> dict[str, Any]:
|
||||
"""Read auth.json and return parsed dict, or an empty compatible store."""
|
||||
path = auth_path or AUTH_JSON_PATH
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(AUTH_JSON_PATH.read_text())
|
||||
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||
return loaded if isinstance(loaded, dict) else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning("Failed to parse %s: %s", AUTH_JSON_PATH, exc)
|
||||
logger.warning("Failed to parse %s: %s", path, exc)
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def read_auth_json():
|
||||
"""Public wrapper for _read_auth_json.
|
||||
|
||||
Used by the streaming layer's credential self-heal logic (#1401) to
|
||||
re-read credentials without coupling to the private helper.
|
||||
"""
|
||||
"""Public wrapper for streaming credential self-heal code."""
|
||||
return _read_auth_json()
|
||||
|
||||
|
||||
def _write_auth_json(data):
|
||||
"""Atomically write auth.json via temp-file rename.
|
||||
def _write_auth_json(data: dict[str, Any], auth_path: Path | None = None) -> Path:
|
||||
"""Atomically write auth.json with owner-only permissions.
|
||||
|
||||
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.)
|
||||
OAuth access/refresh tokens live in this file. The temp file is chmod 0600
|
||||
before rename so the final path never inherits a permissive process umask.
|
||||
"""
|
||||
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))
|
||||
path = auth_path or AUTH_JSON_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
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)
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
try:
|
||||
tmp.chmod(0o600)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to chmod 0600 on %s: %s", tmp, exc)
|
||||
tmp.replace(path)
|
||||
try:
|
||||
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Codex device-code flow ──
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
def start_codex_device_code():
|
||||
"""Start Codex OAuth device-code flow.
|
||||
|
||||
Returns dict: { device_code, user_code, verification_uri, expires_in, interval }
|
||||
Raises RuntimeError on network error.
|
||||
"""
|
||||
params = {
|
||||
"client_id": CODEX_CLIENT_ID,
|
||||
"scope": CODEX_SCOPE,
|
||||
def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) -> Path:
|
||||
"""Persist Codex OAuth credentials to active-profile auth.json."""
|
||||
access_token = str(token_data.get("access_token") or "").strip()
|
||||
refresh_token = str(token_data.get("refresh_token") or "").strip()
|
||||
if not access_token:
|
||||
raise RuntimeError("Codex token exchange did not return an access_token")
|
||||
|
||||
auth_path = Path(hermes_home) / "auth.json"
|
||||
auth = _read_auth_json(auth_path)
|
||||
auth.setdefault("version", 1)
|
||||
pool = auth.setdefault("credential_pool", {})
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
auth["credential_pool"] = pool
|
||||
entries = pool.setdefault("openai-codex", [])
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
pool["openai-codex"] = entries
|
||||
|
||||
now = _now_iso()
|
||||
entry = None
|
||||
# Per Opus advisor on stage-296: also accept the legacy `source ==
|
||||
# "oauth_device"` value so users with prior Codex OAuth credentials
|
||||
# (written by older WebUI versions before this PR's source-key change)
|
||||
# get their existing entry updated in-place rather than accumulating a
|
||||
# stale duplicate pool entry.
|
||||
_accept_sources = {"manual:device_code", "oauth_device"}
|
||||
for candidate in entries:
|
||||
if isinstance(candidate, dict) and candidate.get("source") in _accept_sources:
|
||||
entry = candidate
|
||||
break
|
||||
if entry is None:
|
||||
entry = {
|
||||
"id": "codex-oauth-" + uuid.uuid4().hex[:12],
|
||||
"label": "Codex OAuth",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"base_url": CODEX_BASE_URL,
|
||||
"created_at": now,
|
||||
}
|
||||
entries.insert(0, entry)
|
||||
|
||||
entry.update(
|
||||
{
|
||||
"label": "Codex OAuth",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "manual:device_code",
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"base_url": CODEX_BASE_URL,
|
||||
"last_refresh": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
auth["updated_at"] = now
|
||||
path = _write_auth_json(auth, auth_path)
|
||||
|
||||
try:
|
||||
from api.config import invalidate_credential_pool_cache
|
||||
|
||||
invalidate_credential_pool_cache("openai-codex")
|
||||
except Exception:
|
||||
logger.debug("Failed to invalidate openai-codex credential cache", exc_info=True)
|
||||
|
||||
return path
|
||||
|
||||
|
||||
# Backward-compatible wrapper used by older code/tests.
|
||||
def _save_codex_credentials(token_data):
|
||||
return _persist_codex_credentials(_get_active_hermes_home(), token_data)
|
||||
|
||||
|
||||
# ── Codex protocol ──────────────────────────────────────────────────────────
|
||||
|
||||
def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]:
|
||||
if form:
|
||||
data = urllib.parse.urlencode(payload).encode("utf-8")
|
||||
content_type = "application/x-www-form-urlencoded"
|
||||
else:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
content_type = "application/json"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Content-Type": content_type, "Accept": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _request_codex_user_code() -> dict[str, Any]:
|
||||
return _json_request(CODEX_USER_CODE_URL, {"client_id": CODEX_CLIENT_ID})
|
||||
|
||||
|
||||
def _poll_codex_authorization(device_auth_id: str, user_code: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
return _json_request(
|
||||
CODEX_DEVICE_TOKEN_URL,
|
||||
{"device_auth_id": device_auth_id, "user_code": user_code},
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code in (403, 404):
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -> dict[str, Any]:
|
||||
return _json_request(
|
||||
CODEX_TOKEN_URL,
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": authorization_code,
|
||||
"redirect_uri": CODEX_REDIRECT_URI,
|
||||
"client_id": CODEX_CLIENT_ID,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
form=True,
|
||||
)
|
||||
|
||||
|
||||
def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": "openai-codex",
|
||||
"flow_id": flow_id,
|
||||
"status": flow.get("status", "pending"),
|
||||
"verification_uri": CODEX_VERIFICATION_URI,
|
||||
"user_code": flow.get("user_code", ""),
|
||||
"expires_at": flow.get("expires_at"),
|
||||
"poll_interval_seconds": flow.get("poll_interval_seconds", 5),
|
||||
}
|
||||
data = urllib.parse.urlencode(params).encode()
|
||||
req = urllib.request.Request(CODEX_AUTH_URL, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
|
||||
def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
|
||||
payload = {
|
||||
"ok": True,
|
||||
"provider": "openai-codex",
|
||||
"flow_id": flow_id,
|
||||
"status": flow.get("status", "error"),
|
||||
}
|
||||
if flow.get("status") == "error" and flow.get("error"):
|
||||
payload["error"] = str(flow.get("error"))[:200]
|
||||
return payload
|
||||
|
||||
|
||||
def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
|
||||
for key in (
|
||||
"device_auth_id",
|
||||
"authorization_code",
|
||||
"code_verifier",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"token_data",
|
||||
):
|
||||
flow.pop(key, None)
|
||||
|
||||
|
||||
def _cleanup_oauth_flows(now: float | None = None) -> None:
|
||||
now = now or time.time()
|
||||
cutoff = now - 300
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
for fid, flow in list(_OAUTH_FLOWS.items()):
|
||||
status = flow.get("status")
|
||||
if status == "pending" and float(flow.get("expires_at") or 0) <= now:
|
||||
flow["status"] = "expired"
|
||||
_drop_sensitive_flow_fields(flow)
|
||||
if status in {"success", "expired", "cancelled", "error"} and float(flow.get("updated_at") or 0) < cutoff:
|
||||
_OAUTH_FLOWS.pop(fid, None)
|
||||
|
||||
|
||||
def _spawn_codex_oauth_worker(flow_id: str) -> None:
|
||||
worker = threading.Thread(target=_run_codex_oauth_worker, args=(flow_id,), daemon=True)
|
||||
worker.start()
|
||||
|
||||
|
||||
def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None:
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
flow = _OAUTH_FLOWS.get(flow_id)
|
||||
if not flow:
|
||||
return
|
||||
flow["status"] = status
|
||||
flow["updated_at"] = time.time()
|
||||
flow.update(fields)
|
||||
if status in {"success", "expired", "cancelled", "error"}:
|
||||
_drop_sensitive_flow_fields(flow)
|
||||
|
||||
|
||||
def _run_codex_oauth_worker(flow_id: str) -> None:
|
||||
while True:
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
|
||||
if not flow:
|
||||
return
|
||||
status = flow.get("status")
|
||||
if status != "pending":
|
||||
return
|
||||
if float(flow.get("expires_at") or 0) <= time.time():
|
||||
_set_flow_status(flow_id, "expired")
|
||||
return
|
||||
|
||||
time.sleep(max(1, int(flow.get("poll_interval_seconds") or 5)))
|
||||
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
live = dict(_OAUTH_FLOWS.get(flow_id) or {})
|
||||
if live.get("status") != "pending":
|
||||
return
|
||||
try:
|
||||
code_resp = _poll_codex_authorization(
|
||||
str(live.get("device_auth_id") or ""),
|
||||
str(live.get("user_code") or ""),
|
||||
)
|
||||
if code_resp is None:
|
||||
continue
|
||||
authorization_code = str(code_resp.get("authorization_code") or "").strip()
|
||||
code_verifier = str(code_resp.get("code_verifier") or "").strip()
|
||||
if not authorization_code or not code_verifier:
|
||||
raise RuntimeError("Device auth response missing authorization_code or code_verifier")
|
||||
tokens = _exchange_codex_authorization(authorization_code, code_verifier)
|
||||
_persist_codex_credentials(Path(live["hermes_home"]), tokens)
|
||||
_set_flow_status(flow_id, "success")
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("Codex OAuth onboarding flow failed: %s", exc)
|
||||
_set_flow_status(flow_id, "error", error=str(exc))
|
||||
return
|
||||
|
||||
|
||||
def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Start the supported onboarding OAuth flow.
|
||||
|
||||
Currently v1 intentionally supports only OpenAI Codex. Other providers are
|
||||
rejected instead of silently falling back to terminal-first setup.
|
||||
"""
|
||||
_cleanup_oauth_flows()
|
||||
provider = str((body or {}).get("provider") or "").strip().lower()
|
||||
if provider not in _ALLOWED_ONBOARDING_OAUTH_PROVIDERS:
|
||||
if provider in _REJECTED_ONBOARDING_OAUTH_PROVIDERS or provider:
|
||||
raise ValueError("Only OpenAI Codex OAuth is supported in WebUI onboarding right now")
|
||||
raise ValueError("provider is required")
|
||||
|
||||
hermes_home = _get_active_hermes_home()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to start Codex OAuth: {e}") from e
|
||||
device = _request_codex_user_code()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to start Codex OAuth: {exc}") from exc
|
||||
|
||||
user_code = str(device.get("user_code") or "").strip()
|
||||
device_auth_id = str(device.get("device_auth_id") or "").strip()
|
||||
if not user_code or not device_auth_id:
|
||||
raise RuntimeError("Device code response missing required fields")
|
||||
|
||||
interval = max(3, int(device.get("interval") or 5))
|
||||
expires_in = int(device.get("expires_in") or CODEX_FLOW_MAX_WAIT_SECONDS)
|
||||
expires_at = time.time() + min(max(expires_in, 60), CODEX_FLOW_MAX_WAIT_SECONDS)
|
||||
flow_id = uuid.uuid4().hex
|
||||
flow = {
|
||||
"provider": "openai-codex",
|
||||
"status": "pending",
|
||||
"device_auth_id": device_auth_id,
|
||||
"user_code": user_code,
|
||||
"expires_at": expires_at,
|
||||
"poll_interval_seconds": interval,
|
||||
"hermes_home": str(hermes_home),
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
_OAUTH_FLOWS[flow_id] = flow
|
||||
_spawn_codex_oauth_worker(flow_id)
|
||||
return _public_start_payload(flow_id, flow)
|
||||
|
||||
|
||||
def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]:
|
||||
_cleanup_oauth_flows()
|
||||
fid = str(flow_id or "").strip()
|
||||
if not fid:
|
||||
raise ValueError("flow_id is required")
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
flow = _OAUTH_FLOWS.get(fid)
|
||||
if not flow:
|
||||
raise KeyError("OAuth flow not found")
|
||||
if flow.get("status") == "pending" and float(flow.get("expires_at") or 0) <= time.time():
|
||||
flow["status"] = "expired"
|
||||
flow["updated_at"] = time.time()
|
||||
_drop_sensitive_flow_fields(flow)
|
||||
return _public_status_payload(fid, dict(flow))
|
||||
|
||||
|
||||
def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
|
||||
fid = str((body or {}).get("flow_id") or "").strip()
|
||||
if not fid:
|
||||
raise ValueError("flow_id is required")
|
||||
with _OAUTH_FLOWS_LOCK:
|
||||
flow = _OAUTH_FLOWS.get(fid)
|
||||
if not flow:
|
||||
return {"ok": True, "provider": "openai-codex", "flow_id": fid, "status": "cancelled"}
|
||||
if flow.get("status") == "pending":
|
||||
flow["status"] = "cancelled"
|
||||
flow["updated_at"] = time.time()
|
||||
_drop_sensitive_flow_fields(flow)
|
||||
return _public_status_payload(fid, dict(flow))
|
||||
|
||||
|
||||
# Backward-compatible names from the abandoned spike. They intentionally do not
|
||||
# expose provider device secrets to callers anymore.
|
||||
def start_codex_device_code():
|
||||
return start_onboarding_oauth_flow({"provider": "openai-codex"})
|
||||
|
||||
|
||||
def poll_codex_token(device_code, interval=5):
|
||||
"""Poll for Codex OAuth token. Generator that yields status dicts.
|
||||
|
||||
Yields:
|
||||
{"status": "polling", "attempt": N, "max_attempts": 40}
|
||||
{"status": "success", "credentials": {...}}
|
||||
{"status": "error", "error": "..."}
|
||||
"""
|
||||
params = {
|
||||
"grant_type": CODEX_GRANT_TYPE_DEVICE,
|
||||
"device_code": device_code,
|
||||
"client_id": CODEX_CLIENT_ID,
|
||||
}
|
||||
data = urllib.parse.urlencode(params).encode()
|
||||
max_attempts = 40 # 40 * 5 = 200s max
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
yield {"status": "polling", "attempt": attempt + 1, "max_attempts": max_attempts}
|
||||
|
||||
req = urllib.request.Request(CODEX_TOKEN_URL, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
token_data = json.loads(resp.read().decode())
|
||||
# Save to auth.json credential_pool
|
||||
_save_codex_credentials(token_data)
|
||||
yield {"status": "success", "credentials": {
|
||||
"access_token": "***",
|
||||
"refresh_token": "***",
|
||||
"token_type": token_data.get("token_type"),
|
||||
"expires_in": token_data.get("expires_in"),
|
||||
}}
|
||||
return
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
try:
|
||||
err_data = json.loads(body)
|
||||
error = err_data.get("error", "")
|
||||
if error == "authorization_pending":
|
||||
time.sleep(interval)
|
||||
continue
|
||||
elif error == "slow_down":
|
||||
time.sleep(interval + 5)
|
||||
continue
|
||||
elif error == "expired_token":
|
||||
yield {"status": "error", "error": "Device code expired. Please try again."}
|
||||
return
|
||||
else:
|
||||
yield {"status": "error", "error": err_data.get("error_description", error)}
|
||||
return
|
||||
except Exception:
|
||||
yield {"status": "error", "error": body[:200]}
|
||||
return
|
||||
except Exception as e:
|
||||
yield {"status": "error", "error": str(e)}
|
||||
return
|
||||
|
||||
yield {"status": "error", "error": "OAuth flow timed out. Please try again."}
|
||||
|
||||
|
||||
def _save_codex_credentials(token_data):
|
||||
"""Save Codex OAuth credentials to auth.json credential_pool."""
|
||||
auth = _read_auth_json()
|
||||
if "credential_pool" not in auth:
|
||||
auth["credential_pool"] = {}
|
||||
pool = auth["credential_pool"]
|
||||
|
||||
if "openai-codex" not in pool:
|
||||
pool["openai-codex"] = []
|
||||
|
||||
# Check if an oauth_device entry already exists (update in place)
|
||||
updated = False
|
||||
_now_iso = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
for entry in pool["openai-codex"]:
|
||||
if entry.get("source") == "oauth_device":
|
||||
entry["access_token"] = token_data.get("access_token", "")
|
||||
entry["refresh_token"] = token_data.get("refresh_token", "")
|
||||
entry["auth_type"] = "oauth"
|
||||
entry["updated_at"] = _now_iso
|
||||
updated = True
|
||||
break
|
||||
|
||||
if not updated:
|
||||
existing_ids = {e["id"] for e in pool.get("openai-codex", [])}
|
||||
for _ in range(3): # retry on collision
|
||||
cred_id = "codex-oauth-" + uuid.uuid4().hex[:8]
|
||||
if cred_id not in existing_ids:
|
||||
break
|
||||
pool["openai-codex"].append({
|
||||
"id": cred_id,
|
||||
"label": "Codex OAuth",
|
||||
"auth_type": "oauth",
|
||||
"source": "oauth_device",
|
||||
"access_token": token_data.get("access_token", ""),
|
||||
"refresh_token": token_data.get("refresh_token", ""),
|
||||
"priority": 1,
|
||||
"created_at": _now_iso,
|
||||
})
|
||||
|
||||
auth["updated_at"] = _now_iso
|
||||
_write_auth_json(auth)
|
||||
yield {"status": "error", "error": "Use /api/onboarding/oauth/poll with flow_id"}
|
||||
@@ -185,8 +185,9 @@ _PROVIDER_CATEGORIES = [
|
||||
]
|
||||
|
||||
_UNSUPPORTED_PROVIDER_NOTE = (
|
||||
"OAuth and advanced provider flows such as Nous Portal, OpenAI Codex, and GitHub "
|
||||
"Copilot are still terminal-first. Use `hermes model` for those flows."
|
||||
"Advanced provider flows such as Nous Portal and GitHub Copilot are still "
|
||||
"terminal-first. OpenAI Codex can be authenticated in this onboarding flow "
|
||||
"when your Hermes config selects the openai-codex provider."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -392,6 +392,7 @@ from api.config import (
|
||||
set_reasoning_display,
|
||||
set_reasoning_effort,
|
||||
create_stream_channel,
|
||||
get_webui_session_save_mode,
|
||||
)
|
||||
from api.helpers import (
|
||||
require,
|
||||
@@ -1252,6 +1253,11 @@ from api.onboarding import (
|
||||
complete_onboarding,
|
||||
probe_provider_endpoint,
|
||||
)
|
||||
from api.oauth import (
|
||||
cancel_onboarding_oauth_flow,
|
||||
poll_onboarding_oauth_flow,
|
||||
start_onboarding_oauth_flow,
|
||||
)
|
||||
|
||||
# Approval system (optional -- graceful fallback if agent not available)
|
||||
try:
|
||||
@@ -2279,38 +2285,19 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"error": "not found"}, status=404)
|
||||
return _handle_clarify_inject(handler, parsed)
|
||||
|
||||
# ── OAuth (Codex device-code) ──
|
||||
if parsed.path == "/api/oauth/codex/start":
|
||||
"""Start Codex device-code OAuth flow. Returns user_code + verification_uri."""
|
||||
try:
|
||||
from api.oauth import start_codex_device_code
|
||||
result = start_codex_device_code()
|
||||
return j(handler, result)
|
||||
except Exception as e:
|
||||
return j(handler, {"error": str(e)}, status=500)
|
||||
|
||||
if parsed.path == "/api/oauth/codex/poll":
|
||||
"""SSE endpoint for polling Codex OAuth token."""
|
||||
if parsed.path == "/api/onboarding/oauth/poll":
|
||||
qs = parse_qs(parsed.query)
|
||||
device_code = qs.get("device_code", [""])[0]
|
||||
if not device_code:
|
||||
return j(handler, {"error": "device_code required"}, status=400)
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "text/event-stream")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
handler.send_header("Connection", "keep-alive")
|
||||
handler.end_headers()
|
||||
flow_id = qs.get("flow_id", [""])[0]
|
||||
try:
|
||||
from api.oauth import poll_codex_token
|
||||
for event in poll_codex_token(device_code):
|
||||
handler.wfile.write(f"data: {json.dumps(event)}\n\n".encode())
|
||||
handler.wfile.flush()
|
||||
if event.get("status") in ("success", "error"):
|
||||
break
|
||||
except Exception as e:
|
||||
handler.wfile.write(f"data: {json.dumps({'status': 'error', 'error': str(e)})}\n\n".encode())
|
||||
handler.wfile.flush()
|
||||
return # SSE handled, no JSON response
|
||||
return j(
|
||||
handler,
|
||||
poll_onboarding_oauth_flow(flow_id),
|
||||
extra_headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
except KeyError as e:
|
||||
return bad(handler, str(e), 404)
|
||||
|
||||
# ── Cron API (GET) ──
|
||||
# All cron handlers touch cron.jobs which resolves HERMES_HOME from
|
||||
@@ -3280,6 +3267,34 @@ def handle_post(handler, parsed) -> bool:
|
||||
handler.wfile.write(response_body)
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/onboarding/oauth/start":
|
||||
from api.auth import is_auth_enabled
|
||||
import os as _os
|
||||
if not is_auth_enabled() and not _os.getenv("HERMES_WEBUI_ONBOARDING_OPEN"):
|
||||
import ipaddress
|
||||
try:
|
||||
_xff = handler.headers.get("X-Forwarded-For", "").split(",")[0].strip()
|
||||
_xri = handler.headers.get("X-Real-IP", "").strip()
|
||||
_raw = handler.client_address[0]
|
||||
addr = ipaddress.ip_address(_xff or _xri or _raw)
|
||||
is_local = addr.is_loopback or addr.is_private
|
||||
except ValueError:
|
||||
is_local = False
|
||||
if not is_local:
|
||||
return bad(handler, "Onboarding OAuth is only available from local networks when auth is not enabled. To bypass this on a remote server, set HERMES_WEBUI_ONBOARDING_OPEN=1.", 403)
|
||||
try:
|
||||
return j(handler, start_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"})
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
except RuntimeError as e:
|
||||
return bad(handler, str(e), 500)
|
||||
|
||||
if parsed.path == "/api/onboarding/oauth/cancel":
|
||||
try:
|
||||
return j(handler, cancel_onboarding_oauth_flow(body), extra_headers={"Cache-Control": "no-store"})
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
if parsed.path == "/api/onboarding/setup":
|
||||
# Writing API keys to disk - restrict to local/private networks unless auth is active.
|
||||
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
|
||||
@@ -5056,6 +5071,68 @@ def _handle_background(handler, body):
|
||||
return j(handler, {"task_id": task_id, "stream_id": stream_id, "session_id": bg.session_id})
|
||||
|
||||
|
||||
def _checkpoint_user_message_for_eager_session_save(s, msg: str, attachments, started_at: float | None) -> None:
|
||||
"""Materialize the current user turn for eager first-turn persistence.
|
||||
|
||||
The streaming thread still receives ``pending_user_message`` so existing
|
||||
cancel/recovery/final-merge paths keep their current contract. Eager mode
|
||||
only adds a durable display-message checkpoint before the agent launches.
|
||||
"""
|
||||
if not msg:
|
||||
return
|
||||
existing = list(getattr(s, "messages", None) or [])
|
||||
if existing:
|
||||
latest = existing[-1]
|
||||
if isinstance(latest, dict) and latest.get("role") == "user":
|
||||
latest_text = " ".join(str(latest.get("content") or "").split())
|
||||
msg_text = " ".join(str(msg or "").split())
|
||||
if latest_text == msg_text:
|
||||
return
|
||||
user_msg = {"role": "user", "content": msg}
|
||||
if isinstance(started_at, (int, float)) and started_at > 0:
|
||||
user_msg["timestamp"] = int(started_at)
|
||||
if attachments:
|
||||
user_msg["attachments"] = list(attachments)
|
||||
s.messages.append(user_msg)
|
||||
|
||||
|
||||
def _prepare_chat_start_session_for_stream(
|
||||
s,
|
||||
*,
|
||||
msg: str,
|
||||
attachments,
|
||||
workspace: str,
|
||||
model: str,
|
||||
model_provider,
|
||||
stream_id: str,
|
||||
started_at: float | None = None,
|
||||
):
|
||||
"""Persist chat-start state according to webui.session_save_mode.
|
||||
|
||||
``deferred`` keeps the existing sidecar/WAL-backed behaviour: save pending
|
||||
fields but leave the display transcript empty until the agent merges the
|
||||
result. ``eager`` additionally writes the current user turn into messages so
|
||||
a process restart immediately after /api/chat/start preserves the prompt as
|
||||
a normal session message. Empty sessions are never saved here because this
|
||||
helper only runs after a non-empty message is validated.
|
||||
"""
|
||||
s.workspace = workspace
|
||||
s.model = model
|
||||
s.model_provider = model_provider
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = msg
|
||||
s.pending_attachments = attachments
|
||||
s.pending_started_at = started_at if started_at is not None else time.time()
|
||||
if get_webui_session_save_mode() == "eager":
|
||||
_checkpoint_user_message_for_eager_session_save(
|
||||
s,
|
||||
msg,
|
||||
attachments,
|
||||
s.pending_started_at,
|
||||
)
|
||||
s.save()
|
||||
|
||||
|
||||
def _handle_chat_start(handler, body):
|
||||
try:
|
||||
require(body, "session_id")
|
||||
@@ -5103,14 +5180,15 @@ def _handle_chat_start(handler, body):
|
||||
_clear_stale_stream_state(s)
|
||||
stream_id = uuid.uuid4().hex
|
||||
with _get_session_agent_lock(s.session_id):
|
||||
s.workspace = workspace
|
||||
s.model = model
|
||||
s.model_provider = model_provider
|
||||
s.active_stream_id = stream_id
|
||||
s.pending_user_message = msg
|
||||
s.pending_attachments = attachments
|
||||
s.pending_started_at = time.time()
|
||||
s.save()
|
||||
_prepare_chat_start_session_for_stream(
|
||||
s,
|
||||
msg=msg,
|
||||
attachments=attachments,
|
||||
workspace=workspace,
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
stream_id=stream_id,
|
||||
)
|
||||
set_last_workspace(workspace)
|
||||
stream = create_stream_channel()
|
||||
with STREAMS_LOCK:
|
||||
|
||||
@@ -1164,6 +1164,17 @@ def _find_current_user_turn(messages, msg_text):
|
||||
return fallback
|
||||
|
||||
|
||||
def _drop_checkpointed_current_user_from_context(messages, msg_text):
|
||||
"""Return model history without an eager-checkpointed current user turn."""
|
||||
history = list(messages or [])
|
||||
if not history:
|
||||
return history
|
||||
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
|
||||
if current_user_key and _message_identity(history[-1]) == current_user_key:
|
||||
return history[:-1]
|
||||
return history
|
||||
|
||||
|
||||
def _merge_display_messages_after_agent_result(previous_display, previous_context, result_messages, msg_text):
|
||||
"""Keep UI transcript durable while allowing model context to compact.
|
||||
|
||||
@@ -1191,8 +1202,20 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
|
||||
|
||||
merged = previous_display[:]
|
||||
seen = {_message_identity(m) for m in merged}
|
||||
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
|
||||
for msg in candidates:
|
||||
key = _message_identity(msg)
|
||||
if (
|
||||
key is not None
|
||||
and key == current_user_key
|
||||
and merged
|
||||
and _message_identity(merged[-1]) == key
|
||||
):
|
||||
# Eager session-save mode can checkpoint the current user turn
|
||||
# before the agent runs. When the agent returns that same user turn
|
||||
# in result_messages, keep the durable checkpoint and append only
|
||||
# the assistant/tool delta.
|
||||
continue
|
||||
if _is_context_compression_marker(msg) and key is not None and key in seen:
|
||||
continue
|
||||
merged.append(copy.deepcopy(msg))
|
||||
@@ -1620,9 +1643,11 @@ def _run_agent_streaming(
|
||||
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
|
||||
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
|
||||
|
||||
# Throttle: emit metering events at most every 100 ms so the TPS label
|
||||
# feels live during fast token streams without flooding the SSE channel.
|
||||
# Throttle: emit metering events at most every 100 ms so the per-message
|
||||
# TPS label feels live during fast token streams without flooding SSE.
|
||||
_metering_last_emit = [time.monotonic() - 1] # fire immediately on first token
|
||||
_metering_output_deltas = [0]
|
||||
_metering_reasoning_deltas = [0]
|
||||
|
||||
def _emit_metering():
|
||||
now = time.monotonic()
|
||||
@@ -1631,6 +1656,8 @@ def _run_agent_streaming(
|
||||
_metering_last_emit[0] = now
|
||||
stats = meter().get_stats()
|
||||
stats['session_id'] = stream_id
|
||||
stats.setdefault('tps_available', False)
|
||||
stats.setdefault('estimated', False)
|
||||
put('metering', stats)
|
||||
|
||||
def on_token(text):
|
||||
@@ -1642,8 +1669,11 @@ def _run_agent_streaming(
|
||||
if stream_id in STREAM_PARTIAL_TEXT:
|
||||
STREAM_PARTIAL_TEXT[stream_id] += str(text)
|
||||
put('token', {'text': text})
|
||||
# Update global throughput meter
|
||||
meter().record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))
|
||||
# Update live throughput from stream delta callbacks, not from
|
||||
# byte/character length. If a backend cannot provide live deltas,
|
||||
# the frontend hides TPS rather than showing an estimate.
|
||||
_metering_output_deltas[0] += 1
|
||||
meter().record_token(stream_id, _metering_output_deltas[0])
|
||||
_emit_metering()
|
||||
|
||||
def on_reasoning(text):
|
||||
@@ -1655,8 +1685,9 @@ def _run_agent_streaming(
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
STREAM_REASONING_TEXT[stream_id] += str(text)
|
||||
put('reasoning', {'text': str(text)})
|
||||
# Track reasoning tokens in the meter so TPS reflects all AI output
|
||||
meter().record_reasoning(stream_id, len(_reasoning_text))
|
||||
# Track reasoning deltas in the meter so live TPS reflects all AI output.
|
||||
_metering_reasoning_deltas[0] += 1
|
||||
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
|
||||
_emit_metering()
|
||||
|
||||
# Pre-initialise the activity counter here so on_tool (which
|
||||
@@ -1690,7 +1721,8 @@ def _run_agent_streaming(
|
||||
if stream_id in STREAM_REASONING_TEXT:
|
||||
STREAM_REASONING_TEXT[stream_id] += str(reason_text)
|
||||
put('reasoning', {'text': str(reason_text)})
|
||||
meter().record_reasoning(stream_id, len(_reasoning_text))
|
||||
_metering_reasoning_deltas[0] += 1
|
||||
meter().record_reasoning(stream_id, _metering_reasoning_deltas[0])
|
||||
_emit_metering()
|
||||
return
|
||||
|
||||
@@ -2059,7 +2091,10 @@ def _run_agent_streaming(
|
||||
# Truthy-check covers None, missing-attr, and 0 uniformly.
|
||||
_turn_started_at = _pending_started_at if _pending_started_at else time.time()
|
||||
_previous_messages = list(s.messages or [])
|
||||
_previous_context_messages = list(_session_context_messages(s))
|
||||
_previous_context_messages = _drop_checkpointed_current_user_from_context(
|
||||
_session_context_messages(s),
|
||||
msg_text,
|
||||
)
|
||||
_pre_compression_count = getattr(
|
||||
getattr(agent, 'context_compressor', None),
|
||||
'compression_count', 0,
|
||||
@@ -2459,10 +2494,15 @@ def _run_agent_streaming(
|
||||
_turn_duration_seconds = max(0.0, time.time() - float(_turn_started_at))
|
||||
except Exception:
|
||||
_turn_duration_seconds = 0.0
|
||||
_turn_tps = None
|
||||
if output_tokens and _turn_duration_seconds > 0:
|
||||
_turn_tps = round(float(output_tokens) / _turn_duration_seconds, 1)
|
||||
if s.messages:
|
||||
for _dm in reversed(s.messages):
|
||||
if isinstance(_dm, dict) and _dm.get('role') == 'assistant':
|
||||
_dm['_turnDuration'] = round(_turn_duration_seconds, 3)
|
||||
if _turn_tps is not None:
|
||||
_dm['_turnTps'] = _turn_tps
|
||||
break
|
||||
# Persist context window data on the session so the context-ring
|
||||
# indicator survives a page reload (#1318). Must run BEFORE
|
||||
@@ -2517,6 +2557,8 @@ def _run_agent_streaming(
|
||||
'estimated_cost': estimated_cost,
|
||||
'duration_seconds': round(_turn_duration_seconds, 3),
|
||||
}
|
||||
if _turn_tps is not None:
|
||||
usage['tps'] = _turn_tps
|
||||
# Include context window data from the agent's compressor for the UI indicator.
|
||||
# The session-level persistence happens above (before s.save()) so the values
|
||||
# survive a page reload; this block only populates the live SSE usage payload.
|
||||
@@ -2567,9 +2609,11 @@ def _run_agent_streaming(
|
||||
logger.debug("Failed to drain pending steer for session %s", session_id)
|
||||
raw_session = s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}
|
||||
put('done', {'session': redact_session_data(raw_session), 'usage': usage})
|
||||
# Emit metering stats for the header TPS label
|
||||
# Emit one last metering packet for the live message-header TPS label.
|
||||
meter_stats = meter().get_stats()
|
||||
meter_stats['session_id'] = session_id
|
||||
meter_stats.setdefault('tps_available', False)
|
||||
meter_stats.setdefault('estimated', False)
|
||||
put('metering', meter_stats)
|
||||
if _should_bg_title and _u0 and _a0:
|
||||
threading.Thread(
|
||||
|
||||
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -1238,6 +1238,7 @@ function applyBotName(){
|
||||
_bootSettings=s;
|
||||
window._sendKey=s.send_key||'enter';
|
||||
window._showTokenUsage=!!s.show_token_usage;
|
||||
window._showTps=!!s.show_tps;
|
||||
window._showCliSessions=!!s.show_cli_sessions;
|
||||
window._soundEnabled=!!s.sound_enabled;
|
||||
window._notificationsEnabled=!!s.notifications_enabled;
|
||||
@@ -1271,6 +1272,7 @@ function applyBotName(){
|
||||
}catch(e){
|
||||
window._sendKey='enter';
|
||||
window._showTokenUsage=false;
|
||||
window._showTps=false;
|
||||
window._showCliSessions=false;
|
||||
window._soundEnabled=false;
|
||||
window._notificationsEnabled=false;
|
||||
|
||||
@@ -829,6 +829,13 @@
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowTps" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
<span>Show token speed (TPS)</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays tokens per second in assistant message headers while streaming and after a response completes. Off by default.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsSimplifiedToolCalling" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
|
||||
@@ -903,6 +903,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
if(typeof d.usage.duration_seconds==='number'){
|
||||
lastAsst._turnDuration=d.usage.duration_seconds;
|
||||
}
|
||||
if(typeof d.usage.tps==='number'&&d.usage.tps>0){
|
||||
lastAsst._turnTps=d.usage.tps;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(d.session.tool_calls&&d.session.tool_calls.length){
|
||||
@@ -984,16 +987,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
});
|
||||
|
||||
source.addEventListener('metering',e=>{
|
||||
// TPS + HIGH/LOW stats for the header chip — emitted at 1 Hz during a stream,
|
||||
// silenced entirely when no sessions are active (ticker exits when idle).
|
||||
try{
|
||||
const d=JSON.parse(e.data||'{}');
|
||||
const el=$('tpsStat');
|
||||
if(!el) return;
|
||||
const tps=typeof d.tps==='number'?d.tps.toFixed(1):'0.0';
|
||||
const high=typeof d.high==='number' && d.high>=0?d.high.toFixed(1)+' high':'—';
|
||||
const low=typeof d.low==='number' && d.low>=0?d.low.toFixed(1)+' low':'';
|
||||
el.textContent=`${tps} t/s · ${high}${low?' · '+low:''}`;
|
||||
if((d.session_id||activeSid)!==activeSid) return;
|
||||
if(d.estimated===true||d.tps_available!==true||typeof d.tps!=='number'||d.tps<=0){
|
||||
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null);
|
||||
return;
|
||||
}
|
||||
if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(d.tps);
|
||||
}catch(_){}
|
||||
});
|
||||
|
||||
|
||||
@@ -265,6 +265,9 @@ function _renderOnboardingBody(){
|
||||
if(currentIsOauth){
|
||||
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
|
||||
const providerLabel=esc(currentProviderName);
|
||||
const codexOauthPendingBody=currentProviderName==='openai-codex'
|
||||
? 'This instance is configured to use <strong>openai-codex</strong>, which uses OAuth rather than an API key. Use the button below to authenticate with ChatGPT, then continue once provider status refreshes.'
|
||||
: t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel);
|
||||
if(isReady){
|
||||
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
|
||||
body.innerHTML=`
|
||||
@@ -288,9 +291,10 @@ function _renderOnboardingBody(){
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||
<div class="onboarding-oauth-icon">⚠</div>
|
||||
<div>
|
||||
<div style="flex:1">
|
||||
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
|
||||
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
|
||||
<p>${codexOauthPendingBody}</p>
|
||||
${currentProviderName==='openai-codex'?`<div style="margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" type="button">${t('oauth_login_codex')}</button></div><div id="codexOAuthFlow" style="display:none;margin-top:12px"></div>`:''}
|
||||
</div>
|
||||
</div>
|
||||
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||
@@ -314,17 +318,6 @@ function _renderOnboardingBody(){
|
||||
${_renderOnboardingApiKeyField()}
|
||||
${_renderOnboardingBaseUrlField(showBaseUrl)}
|
||||
<p class="onboarding-copy">${keyHelp}</p>
|
||||
<div class="onboarding-oauth-card" id="codexOAuthCard">
|
||||
<div class="onboarding-oauth-icon">🔑</div>
|
||||
<div style="flex:1">
|
||||
<strong>${t('oauth_login_codex')}</strong>
|
||||
<p style="margin:6px 0 0;font-size:13px;color:var(--muted);line-height:1.5">
|
||||
${t('onboarding_oauth_switch_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<button class="sm-btn" id="codexOAuthBtn" onclick="startCodexOAuth()" style="margin-left:auto;flex-shrink:0">${t('oauth_login_codex')}</button>
|
||||
</div>
|
||||
<div id="codexOAuthFlow" style="display:none;margin-top:12px"></div>
|
||||
${showBaseUrl?`<p class="onboarding-copy">${t('onboarding_base_url_help')}</p>`:''}
|
||||
<p class="onboarding-copy">${esc(setup.unsupported_note||'')||''}</p>`;
|
||||
return;
|
||||
@@ -552,23 +545,100 @@ async function nextOnboardingStep(){
|
||||
}
|
||||
|
||||
/* ── Codex OAuth device-code flow ── */
|
||||
let _codexOAuthSSE=null;
|
||||
let _codexOAuthPollTimer=null;
|
||||
let _codexOAuthFlowId=null;
|
||||
|
||||
function _clearCodexOAuthPoll(){
|
||||
if(_codexOAuthPollTimer){clearTimeout(_codexOAuthPollTimer);_codexOAuthPollTimer=null;}
|
||||
}
|
||||
|
||||
function _setCodexOAuthButton(enabled){
|
||||
const btn=$('codexOAuthBtn');
|
||||
if(btn){btn.disabled=!enabled;btn.textContent=enabled?t('oauth_login_codex'):'...';}
|
||||
}
|
||||
|
||||
async function copyCodexOAuthCode(code){
|
||||
try{
|
||||
await navigator.clipboard.writeText(code||'');
|
||||
showToast('Code copied');
|
||||
}catch(e){
|
||||
showToast(code||'');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelCodexOAuth(){
|
||||
const flowDiv=$('codexOAuthFlow');
|
||||
const flowId=_codexOAuthFlowId;
|
||||
_clearCodexOAuthPoll();
|
||||
_codexOAuthFlowId=null;
|
||||
if(flowId){
|
||||
try{await api('/api/onboarding/oauth/cancel',{method:'POST',body:JSON.stringify({flow_id:flowId})});}catch(e){}
|
||||
}
|
||||
_setCodexOAuthButton(true);
|
||||
if(flowDiv){
|
||||
flowDiv.innerHTML=`<div class="onboarding-oauth-card"><div class="onboarding-oauth-icon">⏹</div><div><strong>OAuth login cancelled</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">Start again whenever you're ready.</p></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderCodexOAuthTerminal(status,message){
|
||||
const flowDiv=$('codexOAuthFlow');
|
||||
if(!flowDiv)return;
|
||||
const ok=status==='success';
|
||||
const icon=ok?'✅':status==='expired'?'⌛':status==='cancelled'?'⏹':'❌';
|
||||
const title=ok?t('oauth_codex_success'):(status==='expired'?t('oauth_codex_expired'):(status==='cancelled'?'OAuth login cancelled':t('oauth_codex_error')));
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card ${ok?'onboarding-oauth-ready':''}" ${ok?'':'style="border-color:var(--error,#e55)"'}>
|
||||
<div class="onboarding-oauth-icon">${icon}</div>
|
||||
<div><strong>${title}</strong><p style="margin-top:6px;color:var(--muted);font-size:13px">${esc(message||'')}</p></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function _pollCodexOAuth(){
|
||||
const flowId=_codexOAuthFlowId;
|
||||
if(!flowId)return;
|
||||
try{
|
||||
const resp=await api('/api/onboarding/oauth/poll?flow_id='+encodeURIComponent(flowId));
|
||||
const status=(resp&&resp.status)||'error';
|
||||
if(status==='pending'){
|
||||
_codexOAuthPollTimer=setTimeout(_pollCodexOAuth,3000);
|
||||
return;
|
||||
}
|
||||
_clearCodexOAuthPoll();
|
||||
_codexOAuthFlowId=null;
|
||||
_setCodexOAuthButton(true);
|
||||
if(status==='success'){
|
||||
_renderCodexOAuthTerminal('success','Credentials saved to the Hermes credential pool. Refreshing provider status…');
|
||||
showToast(t('oauth_codex_success'));
|
||||
try{await loadOnboardingWizard();}catch(e){}
|
||||
}else if(status==='expired'){
|
||||
_renderCodexOAuthTerminal('expired','The code expired. Start a new login flow to try again.');
|
||||
}else if(status==='cancelled'){
|
||||
_renderCodexOAuthTerminal('cancelled','The login flow was cancelled.');
|
||||
}else{
|
||||
_renderCodexOAuthTerminal('error',(resp&&resp.error)||'OAuth login failed. Please try again.');
|
||||
}
|
||||
}catch(e){
|
||||
_clearCodexOAuthPoll();
|
||||
_codexOAuthFlowId=null;
|
||||
_setCodexOAuthButton(true);
|
||||
_renderCodexOAuthTerminal('error',(e&&e.message)||String(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function startCodexOAuth(){
|
||||
const flowDiv=$('codexOAuthFlow');
|
||||
const btn=$('codexOAuthBtn');
|
||||
if(!flowDiv)return;
|
||||
if(btn){btn.disabled=true;btn.textContent='...';}
|
||||
_clearCodexOAuthPoll();
|
||||
_codexOAuthFlowId=null;
|
||||
_setCodexOAuthButton(false);
|
||||
flowDiv.style.display='block';
|
||||
flowDiv.innerHTML=`<div class="onboarding-oauth-card onboarding-oauth-pending"><div class="onboarding-oauth-icon">⏳</div><div><strong>${t('oauth_codex_polling')}</strong><p>Starting device-code flow…</p></div></div>`;
|
||||
try{
|
||||
const resp=await api('/api/oauth/codex/start',{method:'POST'});
|
||||
const resp=await api('/api/onboarding/oauth/start',{method:'POST',body:JSON.stringify({provider:'openai-codex'})});
|
||||
if(resp.error) throw new Error(resp.error);
|
||||
const{device_code,user_code,verification_uri}=resp;
|
||||
if(!device_code||!user_code||!verification_uri) throw new Error('Invalid OAuth response');
|
||||
// Open verification URI in new tab
|
||||
window.open(verification_uri,'_blank');
|
||||
// Show user code prominently
|
||||
const{flow_id,user_code,verification_uri}=resp;
|
||||
if(!flow_id||!user_code||!verification_uri) throw new Error('Invalid OAuth response');
|
||||
_codexOAuthFlowId=flow_id;
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||
<div class="onboarding-oauth-icon">📋</div>
|
||||
@@ -576,60 +646,19 @@ async function startCodexOAuth(){
|
||||
<strong>${t('oauth_codex_step1')}</strong>
|
||||
<p><a href="${esc(verification_uri)}" target="_blank" rel="noopener" style="color:var(--accent);word-break:break-all">${esc(verification_uri)}</a></p>
|
||||
<p style="margin-top:8px"><strong>${t('oauth_codex_step2')}</strong></p>
|
||||
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;margin-top:4px;user-select:all">${esc(user_code)}</code>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
|
||||
<code style="display:inline-block;font-size:18px;letter-spacing:0.1em;background:rgba(255,255,255,.08);padding:6px 14px;border-radius:8px;user-select:all">${esc(user_code)}</code>
|
||||
<button class="sm-btn" type="button" onclick="copyCodexOAuthCode('${esc(user_code)}')">Copy code</button>
|
||||
<button class="sm-btn" type="button" onclick="cancelCodexOAuth()">Cancel</button>
|
||||
</div>
|
||||
<p style="margin-top:8px;color:var(--muted);font-size:13px">${t('oauth_codex_polling')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
// Connect to SSE poll endpoint
|
||||
const pollUrl=new URL('api/oauth/codex/poll?device_code='+encodeURIComponent(device_code),location.href);
|
||||
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
|
||||
_codexOAuthSSE=new EventSource(pollUrl.href);
|
||||
_codexOAuthSSE.onmessage=function(ev){
|
||||
let data;
|
||||
try{data=JSON.parse(ev.data);}catch(e){return;}
|
||||
if(data.status==='success'){
|
||||
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card onboarding-oauth-ready">
|
||||
<div class="onboarding-oauth-icon">✅</div>
|
||||
<div><strong>${t('oauth_codex_success')}</strong>
|
||||
<p>Token saved to credential pool. You can now use Codex as a provider.</p></div>
|
||||
</div>`;
|
||||
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
|
||||
showToast(t('oauth_codex_success'));
|
||||
// Refresh onboarding status in background
|
||||
loadOnboardingWizard().catch(()=>{});
|
||||
}else if(data.status==='error'){
|
||||
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
|
||||
const isExpired=(data.error||'').includes('expired');
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
|
||||
<div class="onboarding-oauth-icon">❌</div>
|
||||
<div><strong>${isExpired?t('oauth_codex_expired'):t('oauth_codex_error')}</strong>
|
||||
<p>${esc(data.error||'Unknown error')}</p></div>
|
||||
</div>`;
|
||||
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
|
||||
}
|
||||
// 'polling' status — keep waiting
|
||||
};
|
||||
_codexOAuthSSE.onerror=function(){
|
||||
if(_codexOAuthSSE){_codexOAuthSSE.close();_codexOAuthSSE=null;}
|
||||
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
|
||||
// Don't overwrite if already showing success/error
|
||||
if(!flowDiv.querySelector('.onboarding-oauth-ready')&&!flowDiv.querySelector('[style*="error"]')){
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
|
||||
<div class="onboarding-oauth-icon">❌</div>
|
||||
<div><strong>${t('oauth_codex_error')}</strong><p>Connection lost. Please try again.</p></div>
|
||||
</div>`;
|
||||
}
|
||||
};
|
||||
_codexOAuthPollTimer=setTimeout(_pollCodexOAuth,Math.max(1000,Number(resp.poll_interval_seconds||3)*1000));
|
||||
}catch(e){
|
||||
flowDiv.innerHTML=`
|
||||
<div class="onboarding-oauth-card" style="border-color:var(--error,#e55)">
|
||||
<div class="onboarding-oauth-icon">❌</div>
|
||||
<div><strong>${t('oauth_codex_error')}</strong><p>${esc(e.message||String(e))}</p></div>
|
||||
</div>`;
|
||||
if(btn){btn.disabled=false;btn.textContent=t('oauth_login_codex');}
|
||||
_clearCodexOAuthPoll();
|
||||
_codexOAuthFlowId=null;
|
||||
_renderCodexOAuthTerminal('error',(e&&e.message)||String(e));
|
||||
_setCodexOAuthButton(true);
|
||||
}
|
||||
}
|
||||
@@ -2922,6 +2922,8 @@ function _preferencesPayloadFromUi(){
|
||||
if(langSel) payload.language=langSel.value;
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
|
||||
const showTpsCb=$('settingsShowTps');
|
||||
if(showTpsCb) payload.show_tps=showTpsCb.checked;
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb) payload.simplified_tool_calling=simplifiedToolCb.checked;
|
||||
const apiRedactCb=$('settingsApiRedact');
|
||||
@@ -2988,6 +2990,11 @@ async function _autosavePreferencesSettings(payload){
|
||||
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
|
||||
if(typeof renderMessages==='function') renderMessages();
|
||||
}
|
||||
if(payload&&payload.show_tps!==undefined){
|
||||
window._showTps=!!(saved&&saved.show_tps);
|
||||
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
|
||||
if(typeof renderMessages==='function') renderMessages();
|
||||
}
|
||||
_settingsPreferencesAutosaveRetryPayload=null;
|
||||
_setPreferencesAutosaveStatus('saved');
|
||||
// Only clear the global dirty flag and hide the unsaved-changes bar when
|
||||
@@ -3128,6 +3135,8 @@ async function loadSettingsPanel(){
|
||||
}
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const showTpsCb=$('settingsShowTps');
|
||||
if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
|
||||
if(simplifiedToolCb){simplifiedToolCb.checked=settings.simplified_tool_calling!==false;simplifiedToolCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
|
||||
const apiRedactCb=$('settingsApiRedact');
|
||||
@@ -3562,9 +3571,10 @@ function _setSettingsAuthButtonsVisible(active){
|
||||
}
|
||||
|
||||
function _applySavedSettingsUi(saved, body, opts){
|
||||
const {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
|
||||
const {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize}=opts;
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
window._showTps=showTps;
|
||||
window._showCliSessions=showCliSessions;
|
||||
window._soundEnabled=body.sound_enabled;
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
@@ -3652,6 +3662,7 @@ async function saveSettings(andClose){
|
||||
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||
const showTps=!!($('settingsShowTps')||{}).checked;
|
||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const theme=($('settingsTheme')||{}).value||'dark';
|
||||
@@ -3668,6 +3679,7 @@ async function saveSettings(andClose){
|
||||
body.font_size=fontSize;
|
||||
body.language=language;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.show_tps=showTps;
|
||||
body.simplified_tool_calling=!!($('settingsSimplifiedToolCalling')||{}).checked;
|
||||
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
@@ -3693,7 +3705,7 @@ async function saveSettings(andClose){
|
||||
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
|
||||
}
|
||||
}
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
|
||||
_settingsDirty=false;
|
||||
_resetSettingsPanelState();
|
||||
@@ -3712,7 +3724,7 @@ async function saveSettings(andClose){
|
||||
if(typeof showToast==='function') showToast('Failed to update default model — settings saved');
|
||||
}
|
||||
}
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showTps,showCliSessions,theme,skin,language,sidebarDensity,fontSize});
|
||||
showToast(t('settings_saved'));
|
||||
_settingsDirty=false;
|
||||
_resetSettingsPanelState();
|
||||
|
||||
@@ -2665,6 +2665,20 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
.msg-role { font-size: 11px; font-weight: 500; margin-bottom: 6px; opacity: .8; letter-spacing: 0; }
|
||||
.msg-role:hover { opacity: 1; }
|
||||
.role-icon { width: 20px; height: 20px; font-size: 9px; }
|
||||
.msg-tps-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 2px;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.msg-time { opacity: .65; font-size: 10px; }
|
||||
.msg-role:hover .msg-time { opacity: 1; }
|
||||
|
||||
|
||||
@@ -3179,15 +3179,43 @@ function _messageHasReasoningPayload(m){
|
||||
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
|
||||
return /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>|<\|turn\|>thinking\n[\s\S]*?<turn\|>/.test(String(m.content||''));
|
||||
}
|
||||
function _assistantRoleHtml(tsTitle=''){
|
||||
const _bn=window._botName||'Hermes';
|
||||
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span></div>`;
|
||||
function _formatTurnTps(value){
|
||||
const n=Number(value);
|
||||
if(!Number.isFinite(n)||n<=0) return '';
|
||||
const fixed=n>=100?Math.round(n).toLocaleString():n>=10?n.toFixed(1):n.toFixed(1);
|
||||
return `${fixed} t/s`;
|
||||
}
|
||||
function _createAssistantTurn(tsTitle=''){
|
||||
function isTpsDisplayEnabled(){
|
||||
return window._showTps===true;
|
||||
}
|
||||
function _assistantRoleHtml(tsTitle='', tpsText=''){
|
||||
const _bn=window._botName||'Hermes';
|
||||
const tps=(isTpsDisplayEnabled()&&tpsText)?`<span class="msg-tps-inline" title="Tokens per second">${esc(tpsText)}</span>`:'';
|
||||
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span>${tps}</div>`;
|
||||
}
|
||||
function _setAssistantTurnTps(turn, tpsText=''){
|
||||
if(!turn) return;
|
||||
const role=turn.querySelector('.msg-role.assistant');
|
||||
if(!role) return;
|
||||
let chip=role.querySelector('.msg-tps-inline');
|
||||
const text=String(tpsText||'').trim();
|
||||
if(!text){if(chip) chip.remove();return;}
|
||||
if(!chip){
|
||||
chip=document.createElement('span');
|
||||
chip.className='msg-tps-inline';
|
||||
chip.title='Tokens per second';
|
||||
role.appendChild(chip);
|
||||
}
|
||||
chip.textContent=text;
|
||||
}
|
||||
function _setLiveAssistantTps(value){
|
||||
_setAssistantTurnTps($('liveAssistantTurn'), isTpsDisplayEnabled()?_formatTurnTps(value):'');
|
||||
}
|
||||
function _createAssistantTurn(tsTitle='', tpsText=''){
|
||||
const row=document.createElement('div');
|
||||
row.className='msg-row assistant-turn';
|
||||
row.dataset.role='assistant';
|
||||
row.innerHTML=`${_assistantRoleHtml(tsTitle)}<div class="assistant-turn-blocks"></div>`;
|
||||
row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}<div class="assistant-turn-blocks"></div>`;
|
||||
return row;
|
||||
}
|
||||
function _assistantTurnBlocks(turn){
|
||||
@@ -3837,7 +3865,7 @@ function renderMessages(){
|
||||
}
|
||||
|
||||
if(!currentAssistantTurn){
|
||||
currentAssistantTurn=_createAssistantTurn(tsTitle);
|
||||
currentAssistantTurn=_createAssistantTurn(tsTitle, isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):'');
|
||||
inner.appendChild(currentAssistantTurn);
|
||||
}
|
||||
const seg=document.createElement('div');
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
Mirrors the structure of test_1003_appearance_autosave.py to verify the
|
||||
preferences-panel autosave pattern is wired correctly:
|
||||
|
||||
- All 13 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- All 14 preference fields use _schedulePreferencesAutosave (not _markSettingsDirty)
|
||||
- Password field MUST still call _markSettingsDirty (security: never autosave)
|
||||
- _preferencesPayloadFromUi covers all 13 fields
|
||||
- _preferencesPayloadFromUi covers all 14 fields
|
||||
- _setPreferencesAutosaveStatus uses the shared i18n keys
|
||||
- Status div exists in static/index.html
|
||||
- _autosavePreferencesSettings clears the dirty flag and hides the unsaved bar
|
||||
@@ -38,6 +38,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
("settingsSendKey", "send_key"),
|
||||
("settingsLanguage", "language"),
|
||||
("settingsShowTokenUsage", "show_token_usage"),
|
||||
("settingsShowTps", "show_tps"),
|
||||
("settingsSimplifiedToolCalling", "simplified_tool_calling"),
|
||||
("settingsShowCliSessions", "show_cli_sessions"),
|
||||
("settingsSyncInsights", "sync_to_insights"),
|
||||
@@ -51,8 +52,8 @@ PREFERENCE_FIELDS_AUTOSAVE = [
|
||||
]
|
||||
|
||||
|
||||
def test_all_13_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 13 preference fields."""
|
||||
def test_all_14_preference_fields_have_autosave_payload_entries():
|
||||
"""_preferencesPayloadFromUi must include all 14 preference fields."""
|
||||
block = _function_block(PANELS_JS, "_preferencesPayloadFromUi")
|
||||
for dom_id, field in PREFERENCE_FIELDS_AUTOSAVE:
|
||||
assert f"$('{dom_id}')" in block, \
|
||||
@@ -62,7 +63,7 @@ def test_all_13_preference_fields_have_autosave_payload_entries():
|
||||
|
||||
|
||||
def test_preference_fields_use_schedule_autosave_not_mark_dirty():
|
||||
"""All 12 listener attachments (excluding bot_name's debounce wrapper) must
|
||||
"""All 13 listener attachments (excluding bot_name's debounce wrapper) must
|
||||
use _schedulePreferencesAutosave. bot_name uses a wrapper but still
|
||||
eventually calls _schedulePreferencesAutosave."""
|
||||
panel = _load_settings_panel_block()
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Regression tests for issue #1362 — Codex OAuth from onboarding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_onboarding_codex_oauth_routes_use_post_start_cancel_and_get_poll():
|
||||
routes = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
get_idx = routes.find("def handle_get(")
|
||||
post_idx = routes.find("def handle_post(")
|
||||
assert get_idx != -1 and post_idx != -1
|
||||
get_body = routes[get_idx:post_idx]
|
||||
post_body = routes[post_idx:]
|
||||
|
||||
assert '"/api/onboarding/oauth/poll"' in get_body
|
||||
assert '"/api/onboarding/oauth/start"' not in get_body
|
||||
assert '"/api/oauth/codex/start"' not in routes
|
||||
assert '"/api/oauth/codex/poll"' not in routes
|
||||
assert '"/api/onboarding/oauth/start"' in post_body
|
||||
assert '"/api/onboarding/oauth/cancel"' in post_body
|
||||
|
||||
|
||||
def test_onboarding_oauth_rejects_non_codex_providers(monkeypatch):
|
||||
import api.oauth as oauth
|
||||
|
||||
for provider in ("anthropic", "claude", "claude-code", "nous", "qwen-oauth", "copilot", "bogus"):
|
||||
with pytest.raises(ValueError):
|
||||
oauth.start_onboarding_oauth_flow({"provider": provider})
|
||||
|
||||
|
||||
def test_start_payload_does_not_leak_provider_device_secrets(monkeypatch, tmp_path):
|
||||
import api.oauth as oauth
|
||||
|
||||
oauth._OAUTH_FLOWS.clear()
|
||||
monkeypatch.setattr(oauth, "_get_active_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(oauth, "_request_codex_user_code", lambda: {
|
||||
"device_auth_id": "device-secret",
|
||||
"user_code": "ABCD-EFGH",
|
||||
"interval": 3,
|
||||
})
|
||||
monkeypatch.setattr(oauth, "_spawn_codex_oauth_worker", lambda flow_id: None)
|
||||
|
||||
payload = oauth.start_onboarding_oauth_flow({"provider": "openai-codex"})
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert payload["provider"] == "openai-codex"
|
||||
assert payload["status"] == "pending"
|
||||
assert payload["verification_uri"] == "https://auth.openai.com/codex/device"
|
||||
assert payload["user_code"] == "ABCD-EFGH"
|
||||
serialized = json.dumps(payload)
|
||||
for forbidden in (
|
||||
"device_auth_id",
|
||||
"device-secret",
|
||||
"authorization_code",
|
||||
"code_verifier",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
):
|
||||
assert forbidden not in serialized
|
||||
|
||||
|
||||
def test_poll_returns_high_level_status_only(monkeypatch, tmp_path):
|
||||
import api.oauth as oauth
|
||||
|
||||
oauth._OAUTH_FLOWS.clear()
|
||||
flow_id = "flow-test"
|
||||
oauth._OAUTH_FLOWS[flow_id] = {
|
||||
"provider": "openai-codex",
|
||||
"status": "pending",
|
||||
"device_auth_id": "device-secret",
|
||||
"user_code": "ABCD-EFGH",
|
||||
"code_verifier": "verifier-secret",
|
||||
"authorization_code": "auth-secret",
|
||||
"expires_at": time.time() + 60,
|
||||
"poll_interval_seconds": 3,
|
||||
"hermes_home": tmp_path,
|
||||
}
|
||||
|
||||
payload = oauth.poll_onboarding_oauth_flow(flow_id)
|
||||
|
||||
assert payload == {"ok": True, "provider": "openai-codex", "flow_id": flow_id, "status": "pending"}
|
||||
serialized = json.dumps(payload)
|
||||
for forbidden in ("device_auth_id", "device-secret", "code_verifier", "authorization_code"):
|
||||
assert forbidden not in serialized
|
||||
|
||||
|
||||
def test_cancel_marks_flow_cancelled_and_poll_stops(tmp_path):
|
||||
import api.oauth as oauth
|
||||
|
||||
oauth._OAUTH_FLOWS.clear()
|
||||
flow_id = "flow-cancel"
|
||||
oauth._OAUTH_FLOWS[flow_id] = {
|
||||
"provider": "openai-codex",
|
||||
"status": "pending",
|
||||
"expires_at": time.time() + 60,
|
||||
"hermes_home": tmp_path,
|
||||
}
|
||||
|
||||
cancelled = oauth.cancel_onboarding_oauth_flow({"flow_id": flow_id})
|
||||
polled = oauth.poll_onboarding_oauth_flow(flow_id)
|
||||
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert polled["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_expired_flow_reports_expired_and_drops_sensitive_lifecycle(tmp_path):
|
||||
import api.oauth as oauth
|
||||
|
||||
oauth._OAUTH_FLOWS.clear()
|
||||
flow_id = "flow-expired"
|
||||
oauth._OAUTH_FLOWS[flow_id] = {
|
||||
"provider": "openai-codex",
|
||||
"status": "pending",
|
||||
"device_auth_id": "device-secret",
|
||||
"expires_at": time.time() - 1,
|
||||
"hermes_home": tmp_path,
|
||||
}
|
||||
|
||||
payload = oauth.poll_onboarding_oauth_flow(flow_id)
|
||||
|
||||
assert payload["status"] == "expired"
|
||||
assert oauth._OAUTH_FLOWS[flow_id]["status"] == "expired"
|
||||
assert "device_auth_id" not in oauth._OAUTH_FLOWS[flow_id]
|
||||
|
||||
|
||||
def test_codex_credentials_written_to_active_profile_auth_json(monkeypatch, tmp_path):
|
||||
import api.oauth as oauth
|
||||
from api.onboarding import _provider_oauth_authenticated
|
||||
|
||||
active_home = tmp_path / "active-profile"
|
||||
realish_home = tmp_path / "process-home"
|
||||
active_home.mkdir()
|
||||
realish_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: realish_home)
|
||||
|
||||
auth_path = oauth._persist_codex_credentials(
|
||||
active_home,
|
||||
{"access_token": "access-secret", "refresh_token": "refresh-secret"},
|
||||
)
|
||||
|
||||
assert auth_path == active_home / "auth.json"
|
||||
assert auth_path.exists()
|
||||
assert not (realish_home / ".hermes" / "auth.json").exists()
|
||||
mode = stat.S_IMODE(auth_path.stat().st_mode)
|
||||
assert mode == 0o600
|
||||
store = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
entry = store["credential_pool"]["openai-codex"][0]
|
||||
assert entry["auth_type"] == "oauth"
|
||||
assert entry["source"] == "manual:device_code"
|
||||
assert entry["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
assert _provider_oauth_authenticated("openai-codex", active_home) is True
|
||||
|
||||
|
||||
def test_frontend_uses_onboarding_oauth_endpoints_and_no_secret_poll_url():
|
||||
js = (REPO / "static" / "onboarding.js").read_text(encoding="utf-8")
|
||||
assert "/api/onboarding/oauth/start" in js
|
||||
assert "/api/onboarding/oauth/poll" in js
|
||||
assert "/api/onboarding/oauth/cancel" in js
|
||||
assert "window.open(verification_uri" not in js
|
||||
assert "device_code=" not in js
|
||||
assert "device_code" not in js
|
||||
assert "flow_id" in js
|
||||
assert "copyCodexOAuthCode" in js
|
||||
assert "cancelCodexOAuth" in js
|
||||
|
||||
|
||||
def test_unsupported_note_no_longer_calls_openai_codex_terminal_first():
|
||||
src = (REPO / "api" / "onboarding.py").read_text(encoding="utf-8")
|
||||
start = src.find("_UNSUPPORTED_PROVIDER_NOTE")
|
||||
body = src[start:start + 400]
|
||||
assert "OpenAI Codex, and GitHub" not in body
|
||||
assert "OpenAI Codex" in body and "authenticated in this onboarding flow" in body
|
||||
assert "Anthropic" not in body
|
||||
assert "Claude" not in body
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Regression coverage for issue #1617: TPS belongs on message headers.
|
||||
|
||||
Product decision:
|
||||
- show live TPS in the assistant message header while streaming when real TPS is available;
|
||||
- persist/show the final TPS at the end of the turn;
|
||||
- do not show placeholder or estimated TPS when unavailable.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
|
||||
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
|
||||
PANELS_JS = (REPO / "static" / "panels.js").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tps_renders_in_message_header_not_global_titlebar():
|
||||
assert "msg-tps-inline" in UI_JS, "assistant message headers need a TPS chip hook"
|
||||
assert "msg-tps-inline" in CSS, "TPS header chip needs an explicit CSS hook"
|
||||
assert "_assistantRoleHtml(tsTitle='', tpsText='')" in UI_JS, (
|
||||
"assistant role/header rendering should accept the per-message TPS text"
|
||||
)
|
||||
assert "_formatTurnTps" in UI_JS, "TPS formatting should be centralized"
|
||||
assert "_turnTps" in UI_JS, "settled assistant messages should render final TPS from message metadata"
|
||||
assert "tpsStat" not in MESSAGES_JS, "live TPS must not target the removed/global titlebar chip"
|
||||
|
||||
|
||||
def test_live_metering_updates_only_real_tps_and_never_placeholders():
|
||||
listener_start = MESSAGES_JS.find("source.addEventListener('metering'")
|
||||
assert listener_start != -1, "messages.js should listen for metering SSE events"
|
||||
listener_end = MESSAGES_JS.find("source.addEventListener('apperror'", listener_start)
|
||||
assert listener_end != -1, "apperror listener should follow metering listener"
|
||||
listener = MESSAGES_JS[listener_start:listener_end]
|
||||
assert "_setLiveAssistantTps" in listener, "live metering should update the live assistant header"
|
||||
assert "tps_available" in listener and "estimated" in listener, (
|
||||
"live TPS display must check availability and reject estimated readings"
|
||||
)
|
||||
assert "0.0 t/s" not in listener, "unavailable TPS should render nothing, not a 0.0 placeholder"
|
||||
assert "'—'" not in listener and '"—"' not in listener, "unavailable TPS should render nothing, not a dash"
|
||||
assert "high" not in listener.lower() and "low" not in listener.lower(), (
|
||||
"message-header TPS should not carry global HIGH/LOW titlebar semantics"
|
||||
)
|
||||
|
||||
|
||||
def test_done_payload_persists_final_tps_when_exact_usage_available():
|
||||
assert "usage['tps']" in STREAMING_PY, "done usage payload should include final exact TPS when available"
|
||||
assert "output_tokens" in STREAMING_PY and "duration_seconds" in STREAMING_PY, (
|
||||
"final TPS should be based on exact completion tokens over measured turn duration"
|
||||
)
|
||||
assert "d.usage.tps" in MESSAGES_JS, "done handler should read final TPS from the usage payload"
|
||||
assert "lastAsst._turnTps" in MESSAGES_JS, "done handler should persist final TPS on the last assistant message"
|
||||
|
||||
|
||||
def test_backend_marks_streaming_metering_availability_explicitly():
|
||||
assert "tps_available" in STREAMING_PY, "metering SSE payloads must explicitly say whether TPS is displayable"
|
||||
assert "estimated" in STREAMING_PY, "metering SSE payloads must explicitly distinguish estimated readings"
|
||||
assert "record_token(stream_id, len(STREAM_PARTIAL_TEXT[stream_id]))" not in STREAMING_PY, (
|
||||
"live TPS must not be derived from streamed character count / byte-size estimates"
|
||||
)
|
||||
|
||||
|
||||
def test_tps_display_setting_is_default_off_and_persisted():
|
||||
assert '"show_tps": False' in CONFIG_PY, "TPS display should be disabled by default"
|
||||
assert '"show_tps"' in CONFIG_PY and "_SETTINGS_BOOL_KEYS" in CONFIG_PY, (
|
||||
"TPS display should be a persisted boolean WebUI setting"
|
||||
)
|
||||
assert "settingsShowTps" in INDEX_HTML, "Preferences needs a user-facing TPS display toggle"
|
||||
assert "payload.show_tps=showTpsCb.checked" in PANELS_JS, (
|
||||
"Preferences autosave should persist the TPS display toggle through /api/settings"
|
||||
)
|
||||
assert "showTpsCb.checked=!!settings.show_tps" in PANELS_JS, (
|
||||
"Settings panel should hydrate the TPS toggle from persisted settings"
|
||||
)
|
||||
assert "window._showTps=!!s.show_tps" in BOOT_JS, (
|
||||
"Boot should hydrate show_tps into a runtime flag"
|
||||
)
|
||||
assert "window._showTps=false" in BOOT_JS, (
|
||||
"Boot fallback should keep TPS hidden when settings cannot load"
|
||||
)
|
||||
|
||||
|
||||
def test_tps_display_hot_applies_when_preferences_autosave():
|
||||
fn_start = PANELS_JS.find("async function _autosavePreferencesSettings")
|
||||
assert fn_start != -1, "preferences autosave function should exist"
|
||||
fn_end = PANELS_JS.find("function _retryPreferencesAutosave", fn_start)
|
||||
assert fn_end != -1, "retry function should follow preferences autosave"
|
||||
fn = PANELS_JS[fn_start:fn_end]
|
||||
assert "payload&&payload.show_tps!==undefined" in fn, (
|
||||
"TPS preference autosave must detect the show_tps field specifically"
|
||||
)
|
||||
assert "window._showTps=!!(saved&&saved.show_tps)" in fn, (
|
||||
"TPS preference autosave should update the runtime flag from the saved response"
|
||||
)
|
||||
assert "clearMessageRenderCache" in fn and "renderMessages" in fn, (
|
||||
"TPS preference autosave should re-render the open transcript without refresh"
|
||||
)
|
||||
|
||||
|
||||
def test_tps_header_rendering_respects_display_setting():
|
||||
assert "function isTpsDisplayEnabled()" in UI_JS, "TPS visibility should be centralized"
|
||||
assert "return window._showTps===true" in UI_JS, "TPS should only render when explicitly enabled"
|
||||
assert "const tps=(isTpsDisplayEnabled()&&tpsText)" in UI_JS, (
|
||||
"settled assistant headers must suppress TPS when the setting is off"
|
||||
)
|
||||
assert "isTpsDisplayEnabled()?_formatTurnTps(value):''" in UI_JS, (
|
||||
"live TPS updates must remove/suppress the chip when the setting is off"
|
||||
)
|
||||
assert "isTpsDisplayEnabled()?_formatTurnTps(m._turnTps):''" in UI_JS, (
|
||||
"reloaded assistant messages must not render persisted TPS while disabled"
|
||||
)
|
||||
@@ -38,7 +38,7 @@ def test_streaming_persists_context_fields_on_session_before_save():
|
||||
# Save call follows shortly after
|
||||
save_call = src.find("\n s.save()", block_start)
|
||||
assert save_call != -1, "s.save() not found after the post-merge marker"
|
||||
assert save_call - block_start < 3000, (
|
||||
assert save_call - block_start < 3400, (
|
||||
"s.save() should be close to the post-merge marker — block expanded unexpectedly. "
|
||||
"If you've added a new pre-save mutation block here, bump this limit."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Regression tests for config-driven first-turn session persistence (#1406)."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import api.config as config
|
||||
import api.models as models
|
||||
import api.routes as routes
|
||||
import api.streaming as streaming
|
||||
from api.models import Session, new_session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_state(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
index_file = session_dir / "_index.json"
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
|
||||
monkeypatch.setattr(config, "SESSION_INDEX_FILE", index_file, raising=False)
|
||||
models.SESSIONS.clear()
|
||||
config.STREAMS.clear()
|
||||
config.CANCEL_FLAGS.clear()
|
||||
config.AGENT_INSTANCES.clear()
|
||||
config.SESSION_AGENT_LOCKS.clear()
|
||||
monkeypatch.setattr(config, "cfg", {})
|
||||
monkeypatch.setattr(config, "_cfg_cache", {})
|
||||
yield session_dir
|
||||
models.SESSIONS.clear()
|
||||
config.STREAMS.clear()
|
||||
config.CANCEL_FLAGS.clear()
|
||||
config.AGENT_INSTANCES.clear()
|
||||
config.SESSION_AGENT_LOCKS.clear()
|
||||
|
||||
|
||||
def test_session_save_mode_defaults_to_deferred_for_missing_config():
|
||||
assert config.get_webui_session_save_mode({}) == "deferred"
|
||||
assert config.get_webui_session_save_mode({"webui": {}}) == "deferred"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["bogus", "", None, 42, {"mode": "eager"}])
|
||||
def test_invalid_session_save_mode_falls_back_to_deferred(raw):
|
||||
assert config.get_webui_session_save_mode({"webui": {"session_save_mode": raw}}) == "deferred"
|
||||
|
||||
|
||||
def test_eager_session_save_mode_is_accepted():
|
||||
assert config.get_webui_session_save_mode({"webui": {"session_save_mode": "eager"}}) == "eager"
|
||||
|
||||
|
||||
def test_eager_mode_still_does_not_save_empty_new_sessions(_isolate_state, monkeypatch):
|
||||
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "eager"}})
|
||||
s = new_session()
|
||||
assert not s.path.exists(), "eager mode must not recreate empty Untitled session files"
|
||||
|
||||
|
||||
def test_deferred_chat_start_persists_pending_only_before_thread(_isolate_state, monkeypatch):
|
||||
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "deferred"}})
|
||||
s = new_session(workspace=str(_isolate_state.parent))
|
||||
routes._prepare_chat_start_session_for_stream(
|
||||
s,
|
||||
msg="hello deferred",
|
||||
attachments=[],
|
||||
workspace=str(_isolate_state.parent),
|
||||
model=s.model,
|
||||
model_provider=s.model_provider,
|
||||
stream_id="stream_deferred",
|
||||
started_at=123.0,
|
||||
)
|
||||
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
|
||||
assert on_disk["messages"] == []
|
||||
assert on_disk["pending_user_message"] == "hello deferred"
|
||||
|
||||
|
||||
def test_eager_chat_start_checkpoints_first_user_message_before_thread(_isolate_state, monkeypatch):
|
||||
monkeypatch.setattr(config, "cfg", {"webui": {"session_save_mode": "eager"}})
|
||||
s = new_session(workspace=str(_isolate_state.parent))
|
||||
routes._prepare_chat_start_session_for_stream(
|
||||
s,
|
||||
msg="hello eager",
|
||||
attachments=[{"name": "note.txt", "path": "", "mime": "text/plain"}],
|
||||
workspace=str(_isolate_state.parent),
|
||||
model=s.model,
|
||||
model_provider=s.model_provider,
|
||||
stream_id="stream_eager",
|
||||
started_at=456.0,
|
||||
)
|
||||
on_disk = json.loads(s.path.read_text(encoding="utf-8"))
|
||||
assert [m["role"] for m in on_disk["messages"]] == ["user"]
|
||||
assert on_disk["messages"][0]["content"] == "hello eager"
|
||||
assert on_disk["messages"][0]["attachments"][0]["name"] == "note.txt"
|
||||
assert on_disk["pending_user_message"] == "hello eager"
|
||||
|
||||
|
||||
def test_eager_wal_repair_does_not_duplicate_checkpointed_user_message(_isolate_state, monkeypatch):
|
||||
s = Session(session_id="eager_repair", messages=[{"role": "user", "content": "survive"}])
|
||||
s.pending_user_message = "survive"
|
||||
s.active_stream_id = "dead_stream"
|
||||
s.pending_started_at = 789.0
|
||||
s.save()
|
||||
|
||||
repaired = models._repair_stale_pending(s)
|
||||
|
||||
assert repaired is True
|
||||
user_messages = [m for m in s.messages if m.get("role") == "user" and m.get("content") == "survive"]
|
||||
assert len(user_messages) == 1
|
||||
assert s.pending_user_message is None
|
||||
assert any(m.get("_error") for m in s.messages if m.get("role") == "assistant")
|
||||
|
||||
|
||||
def test_eager_checkpointed_user_is_removed_from_model_context():
|
||||
context = streaming._drop_checkpointed_current_user_from_context(
|
||||
[
|
||||
{"role": "user", "content": "older"},
|
||||
{"role": "assistant", "content": "prior"},
|
||||
{"role": "user", "content": "current"},
|
||||
],
|
||||
"current",
|
||||
)
|
||||
assert [m["content"] for m in context] == ["older", "prior"]
|
||||
|
||||
|
||||
def test_eager_checkpointed_user_is_not_duplicated_after_agent_result():
|
||||
merged = streaming._merge_display_messages_after_agent_result(
|
||||
previous_display=[{"role": "user", "content": "repeat me"}],
|
||||
previous_context=[],
|
||||
result_messages=[
|
||||
{"role": "user", "content": "repeat me"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
],
|
||||
msg_text="repeat me",
|
||||
)
|
||||
assert [m["role"] for m in merged] == ["user", "assistant"]
|
||||