v5.8.3-rc.2: centralize state helpers

Move shared JSON, lock, review-doc, and direct-provider fallback logic behind explicit SSOT helpers while keeping trusted non-local binds env-only for managed deployments.
This commit is contained in:
Ouroboros 2026-05-08 02:12:09 +03:00
parent 5acde5c398
commit 409a856f7e
27 changed files with 414 additions and 318 deletions

View file

@ -6,7 +6,7 @@
[![macOS 12+](https://img.shields.io/badge/macOS-12%2B-black.svg)](https://github.com/joi-lab/ouroboros-desktop/releases)
[![Linux](https://img.shields.io/badge/Linux-x86__64-orange.svg)](https://github.com/joi-lab/ouroboros-desktop/releases)
[![Windows](https://img.shields.io/badge/Windows-x64-blue.svg)](https://github.com/joi-lab/ouroboros-desktop/releases)
[![Version 5.8.3-rc.1](https://img.shields.io/badge/version-5.8.3--rc.1-green.svg)](VERSION)
[![Version 5.8.3-rc.2](https://img.shields.io/badge/version-5.8.3--rc.2-green.svg)](VERSION)
A self-modifying AI agent that writes its own code, rewrites its own mind, and evolves autonomously. Born February 16, 2026.
@ -102,8 +102,9 @@ The same values can also be provided via environment variables:
|----------|---------|-------------|
| `OUROBOROS_SERVER_HOST` | `127.0.0.1` | Default bind host |
| `OUROBOROS_SERVER_PORT` | `8765` | Default bind port |
| `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD` | unset | Set to `1` only for trusted Docker/Kubernetes deployments where ingress auth, VPN, a private network, or an auth proxy already protects access |
If you bind on anything other than localhost, set `OUROBOROS_NETWORK_PASSWORD` unless the network is fully trusted. The app still starts without it for local lab and Docker workflows when configured manually through env/settings before launch, but the web Settings UI only saves `127.0.0.1` or wildcard `0.0.0.0` hosts and requires a Network Password for wildcard binds. Specific LAN IP binds are manual/env-only so the desktop launcher can keep a reliable loopback health check.
If you bind on anything other than localhost, set `OUROBOROS_NETWORK_PASSWORD` unless the network is already protected outside Ouroboros. The app still starts without it for local lab and Docker workflows when configured manually through env/settings before launch, but the web Settings UI only saves `127.0.0.1` or wildcard `0.0.0.0` hosts and requires a Network Password for wildcard binds unless `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1` is set. Use that flag only when access is already gated by trusted infrastructure such as ingress auth, a VPN, a private network, or an auth proxy. Specific LAN IP binds are manual/env-only so the desktop launcher can keep a reliable loopback health check.
The Files tab uses your home directory by default only for localhost usage. For Docker or other
network-exposed runs, set `OUROBOROS_FILE_BROWSER_DEFAULT` to an explicit directory. Symlink entries are shown and can be read, edited, copied, moved, uploaded into, and deleted intentionally; root-delete protection still applies to the configured root itself.
@ -191,6 +192,12 @@ Required/important environment variables:
| `OUROBOROS_FILE_BROWSER_DEFAULT` | Defaults to `${APP_HOME}` in the image | Explicit root directory exposed in the Files tab |
| `OUROBOROS_SERVER_PORT` | Optional | Override container listen port |
| `OUROBOROS_SERVER_HOST` | Optional | Defaults to `0.0.0.0` in Docker |
| `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD` | Optional | Set to `1` only when Docker/Kubernetes access is already protected by ingress auth, VPN, a private network, or an auth proxy |
For Kubernetes or trusted reverse-proxy deployments, `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1`
lets the Settings UI save ordinary settings while `OUROBOROS_SERVER_HOST=0.0.0.0` and
`OUROBOROS_NETWORK_PASSWORD` is empty. This disables Ouroboros's internal non-loopback password
requirement for that save path; do not use it on an open LAN or public port.
Example: mount a host workspace and expose only that directory in Files:
@ -449,17 +456,17 @@ Full text: [BIBLE.md](BIBLE.md)
| Version | Date | Description |
|---------|------|-------------|
| 5.8.3-rc.2 | 2026-05-08 | **refactor(infra): centralize durable JSON, lock files, review docs, and trusted network bind policy.** The second pre-release pass introduces shared `atomic_write_json`, `read_json_dict`, and exclusive lock helpers for durable state files, consolidates direct-provider review fallback selection and governance-doc loading, and adds `OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1` for trusted Docker/Kubernetes deployments that already enforce access through ingress auth, VPN, private networking, or an auth proxy. |
| 5.8.3-rc.1 | 2026-05-08 | **refactor(core+ui): remove dead compatibility paths and harden shared web escaping.** The first pre-release refactor pass deletes unused LLM/message-bus/marketplace compatibility helpers, retires the hidden Evolution Versions sub-tab in favour of Dashboard → Updates as the single commit/tag surface, migrates legacy source-inspection tests to their real startup/review helpers, and introduces `escapeHtmlText` / `escapeHtmlAttr` plus USD-formatting utilities for safer shared web rendering. Legacy official generation-skill migration remains idempotent for the v5 line and is now documented as v6 cleanup debt. |
| 5.8.2 | 2026-05-07 | **feat(skills): finalize agent-authored skills automatically.** New external skill manifests written by Ouroboros are marked with `.self_authored.json`; `review_skill` now finalizes those skills through deterministic preflight, self-authored PASS state, auto-grants for configured requested keys, enablement, dependency reconciliation, and extension reload. Skill preflight also validates widget render schemas so `action_route`-style mistakes fail before expensive review/load cycles. The data tools add line-range `data_read`, block unseeded `native/` payload writes, and reject serialized tool-result writes, while Repair/light-mode flows allow scoped skill editing without opening repo mutation. **Note on changelog rolloff**: the v5.7.4 patch row was rolled off to respect the P9 5-patch-row cap; its full body remains at git tag `v5.7.4`. |
| 5.8.1 | 2026-05-06 | **fix(ui): keep desktop Chat scrollable across viewport snapshots.** Desktop viewport sizing now leaves `--vvh` CSS-driven as `100dvh` instead of freezing transient `visualViewport.height` pixel snapshots, so `body`/`#app` do not collapse the Chat flex scroll chain until a manual resize. Narrow/mobile keyboard mode still uses dynamic visualViewport pixels, now with a 320px safety floor. The release adds static viewport contract coverage plus a Playwright desktop smoke that overflows `#chat-messages`, verifies `scrollTop` can move, and rechecks after a resize round-trip. **Note on changelog rolloff**: the v5.7.3 patch row was rolled off to respect the P9 5-patch-row cap; its full body remains at git tag `v5.7.3`. |
| 5.8.0-rc.6 | 2026-05-06 | **fix(skills+mobile): make Hub skills installable and polish lifecycle UX.** Extension loading now treats `.ouroboros_env` as generated dependency cache during staging and exposes isolated Python site-packages to in-process extensions, so OuroborosHub DuckDuckGo can import its reviewed `ddgs` dependency after install. The Skills UI keeps review/repair/grant work explicit with a visible primary action, clickable status/lock hints, and a shared confirm dialog, while ClawHub and OuroborosHub marketplace cards share the same lifecycle install/review spinner and live hint text. Mobile Chat also adopts PR #50's visualViewport keyboard lock hardening: scoped touch guards, scrollable transcript/composer/live timeline surfaces, safe text-node targets, and wide-viewport listener cleanup. |
| 5.8.0-rc.2 | 2026-05-06 | **feat(skills+ci): make Repair observable and add UI/Docker/Windows coverage.** User-managed skills accidentally left under `data/skills/native/` now migrate into the repairable external bucket, visible Fix/Heal copy is renamed to Repair, skill review/repair lifecycle work emits chat live-card progress, and declared skill dependencies share one isolated install/readiness contract across marketplace and reviewed manifests. The release also fixes mobile Updates wrapping, Widgets refresh feedback, and Skills re-review affordances; adds Playwright browser-tool/UI smoke tests, Docker UI/portable lanes, Cloud.ru provider integration coverage, and marker guards; and switches Windows packaging to Playwright's headless shell with path-length guards so Explorer zip extraction stays below MAX_PATH. **Note on changelog rolloff**: the v5.3.0 minor row was rolled off to respect the P9 5-minor-row cap; its full body remains at git tag `v5.3.0`. |
| 5.7.6 | 2026-05-06 | **fix(ci): read bughunt source as UTF-8 on Windows.** The Windows tag CI run used the platform default cp1252 codec for `Path.read_text()` in `tests/test_bughunt_fixes.py`, which failed on UTF-8 source characters in `ouroboros/agent.py`. The regression test now reads the inspected source with `encoding="utf-8"`, matching the repository source encoding and keeping the full Windows test matrix green. **Note on changelog rolloff**: the v5.7.1 patch row was rolled off to respect the P9 5-patch-row cap; its full body remains at git tag `v5.7.1`. |
| 5.7.5 | 2026-05-05 | **fix(mobile): keep chat composer visible with soft keyboard.** On narrow viewports (`≤640px`), `web/app.js` now detects soft-keyboard visibility from the `visualViewport` height delta, exposes both `--vvh` and `--vvh-offset`, and toggles `body.keyboard-open`. CSS hides the mobile nav rail while the keyboard is open and pins active Chat (`#page-chat.active`) to the visual viewport as a flex-column stack so the header, transcript, and composer fill the available space without gaps without revealing Chat over other pages. Regression coverage in `tests/test_chat_logs_ui.py` asserts the JS contract and keyboard-open layout selectors. **Note on changelog rolloff**: the v5.6.4 patch row was rolled off to respect the P9 5-patch-row cap; its full body remains at git tag `v5.6.4`. |
| 5.7.0 | 2026-05-02 | **feat(skills+ui): expand the skill platform and repair mobile/dashboard UX.** The Skills platform gains core-visible `review_skill`, a heal-safe `skill_preflight` validator, expanded script runtimes (`deno`, `ruby`, `go`), larger `skill_exec` output caps, async extension tool handling, `PluginAPI.get_runtime_info`, extension-provided Settings sections, sandboxed `kind: module` widgets, declarative `map`/`calendar`/`kanban` components, dependency-state enforcement, and broader protection for skill provenance/control-plane files. The UI moves About into Settings, restores horizontal mobile Settings tabs, centers the mobile nav, differentiates Dashboard with a gauge icon, fixes Dashboard panel scrolling and duplicate labels, repairs the chat input fade/glass effect, anchors Skills kebab menus, stabilizes Widgets loading, and fixes ClawHub search/Official filtering/install progress. `video_gen` leaves the bundled seed (weather remains the only built-in example); a companion OuroborosHub catalog update (`joi-lab/OuroborosHub@68b3358`) publishes DuckDuckGo and Perplexity as official installable skills. **Note on changelog rolloff**: the v5.2.3 patch row was retained in the v5.7.0 minor release and rolled off in v5.7.1; its full body remains in git history. |
| 5.6.0 | 2026-05-01 | **feat(ui): add managed updates, settings-hosted observability, smart skill activation, and mobile polish.** Settings becomes the app hub for operational surfaces: Logs, Evolution, Updates, and Costs now live as first-class Settings sub-tabs while the mobile Settings page switches to a native stack-style section list. The chat budget pill opens Costs directly, and the chat composer now uses a flex layout with the iOS `interactive-widget` viewport hint removed so the input stays anchored above mobile keyboards. The new Updates tab exposes the existing launcher-managed `managed` remote as a safe owner-facing Update flow: passive status by default, explicit Check for updates, rescue snapshot before apply, exact target-SHA pinning, and automatic local-keep preservation for divergent committed work. Skills keep the visual toggle but turn it into the activation entry point: enabling can run review, request owner key grants through the desktop launcher confirmation bridge, and then enable without exposing duplicate grant-error copy. Buttons now share one documented design system, widget inline cards preserve state across tab switches, and the change was visually checked across desktop and mobile. **Note on changelog rolloff**: the v5.1.0 minor row was rolled off to respect the P9 5-minor-row cap; its full body remains at git tag `v5.1.0`. |
| 5.5.0 | 2026-05-01 | **feat(skills): add official hub, lifecycle queue, isolated dependency installs, and robust files/widgets UX.** Skill lifecycle operations now run through a single FIFO lane with visible queue events, tab badges, and chat summaries. ClawHub install specs are normalized separately: reviewed Python wheel-only and npm no-script dependencies install under `.ouroboros_env`, while Cargo/global host mutations stay manual. Skills UI is now **My skills / ClawHub / OuroborosHub**, backed by the public static-catalog repo `joi-lab/OuroborosHub`, with in-progress/failed virtual cards and clearer review/toggle controls. Widgets keep session state across tab switches, Files downloads use the desktop Downloads bridge with web fallback, A2A is part of the base runtime, Playwright repair is aligned with build scripts, and defaults explicitly roll back retired Opus 4.7 lanes to Opus 4.6 while moving GPT lanes to GPT-5.5 / GPT-5.5 Pro review models. |
Older releases are preserved in Git tags and GitHub releases. The 5.2.0, 5.3.0, 5.3.x release-candidate, 5.4.0, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
Older releases are preserved in Git tags and GitHub releases. The 5.2.0, 5.3.0, 5.3.x release-candidate, 5.4.0, 5.7.5, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain at their git tags.
---

View file

@ -1 +1 @@
5.8.3-rc.1
5.8.3-rc.2

View file

@ -1,4 +1,4 @@
# Ouroboros v5.8.3-rc.1 — Architecture & Reference
# Ouroboros v5.8.3-rc.2 — Architecture & Reference
This document describes every component, page, button, API endpoint, and data flow.
It is the single source of truth for how the system works. Keep it updated.
@ -84,7 +84,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── tool_aliases.py ← OpenClaw-compatible alias boundary; normalizes alias names/args to canonical tools before safety, timeout, dispatch, and truncation
├── tool_capabilities.py ← SSOT for tool sets (core, parallel-safe, truncation, browser)
├── tool_policy.py ← Tool access policy and gating (imports from tool_capabilities)
├── utils.py ← Shared utilities
├── utils.py ← Shared utilities; v5.8.3-rc.2 SSOT for JSON atomic writes/reads, UTC timestamps, hashes, log sanitization, and subprocess helpers
├── world_profiler.py ← System profile generator (WORLD.md)
├── contracts/ ← Frozen ABI (Phase 1 Protocols + TypedDicts + SkillManifest; Phase 4 adds plugin_api.py with PluginAPI + ExtensionRegistrationError + permission/route-method/forbidden-settings tuples)
│ ├── tool_context.py ← ToolContextProtocol (minimum tool ABI, duck-typed)
@ -161,7 +161,8 @@ Dockerfile ← Docker image (web UI runtime)
│ │ ├── ARCHITECTURE.md ← This document
│ │ ├── DEVELOPMENT.md ← Engineering handbook (naming, entity types, review protocol)
│ │ ├── CHECKLISTS.md ← Pre-commit review checklists (single source of truth)
│ │ └── CREATING_SKILLS.md ← Skill author guide (manifest schema, PluginAPI, widgets, publishing)
│ │ ├── CREATING_SKILLS.md ← Skill author guide (manifest schema, PluginAPI, widgets, publishing)
│ │ └── DEPLOYMENT.md ← Deployment notes, including trusted Docker/Kubernetes non-local bind policy
│ └── prompts/ ← System prompts (SYSTEM.md, SAFETY.md, CONSCIOUSNESS.md)
├── data/
│ ├── settings.json ← User settings (API keys, models, budget)
@ -402,6 +403,7 @@ because their untrusted values frequently land in `data-*`, `src`, `alt`, and
Network/Docker access requires an explicit `OUROBOROS_FILE_BROWSER_DEFAULT` directory.
- **Network policy**: `OUROBOROS_NETWORK_PASSWORD` is optional. When configured, non-loopback browser/API access is gated.
When omitted, the full HTTP/WebSocket surface remains reachable by design. `/api/health` always stays public.
`OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1` is a Docker/Kubernetes escape hatch for deployments already protected by ingress auth, VPN, private networking, or an auth proxy; it lets Settings save ordinary changes while `OUROBOROS_SERVER_HOST` is non-localhost and `OUROBOROS_NETWORK_PASSWORD` is empty, but still returns a visible warning.
- **Symlink policy**: entries are constrained lexically to the configured root, but symlink targets may resolve outside that root intentionally.
External symlink paths support list/read/download/content/write/mkdir/upload/copy/move/delete. Root-delete protection still applies only to the configured root itself.
- **Transfer semantics**: copy/move of symlink entries preserves the link object; writing through a symlink-backed file edits the target content.
@ -1667,6 +1669,7 @@ Settings file: `~/Ouroboros/data/settings.json`. File-locked for concurrent acce
| TELEGRAM_CHAT_ID | "" | Optional. Pin replies to a specific Telegram chat |
| OUROBOROS_NETWORK_PASSWORD | "" | Optional. Enables the non-loopback auth gate when set; empty still allows open bind, but startup logs a warning |
| OUROBOROS_SERVER_HOST | 127.0.0.1 | Server bind host. Use `0.0.0.0` for LAN/Docker access; restart required. |
| OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD | unset | Env-only Docker/Kubernetes escape hatch. When set to `1`, Settings may save ordinary changes while a wildcard/non-localhost bind has no `OUROBOROS_NETWORK_PASSWORD`; use only behind ingress auth, VPN, private networking, or an auth proxy. |
| OUROBOROS_MODEL | anthropic/claude-opus-4.6 | Main reasoning model |
| OUROBOROS_MODEL_CODE | anthropic/claude-opus-4.6 | Code editing model |
| OUROBOROS_MODEL_LIGHT | anthropic/claude-sonnet-4.6 | Fast/cheap model (safety, consciousness) |

27
docs/DEPLOYMENT.md Normal file
View file

@ -0,0 +1,27 @@
# DEPLOYMENT.md — Deployment Notes
## Trusted Docker / Kubernetes Non-Local Binds
By default, saving `OUROBOROS_SERVER_HOST=0.0.0.0` through the Settings UI
requires `OUROBOROS_NETWORK_PASSWORD` in the same save. This keeps desktop and
local-network launches from accidentally exposing the full Ouroboros HTTP and
WebSocket surface without the built-in password gate.
Trusted container deployments may opt out with:
```bash
OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1
```
Use this flag only when access is already restricted by external
infrastructure, for example:
- ingress authentication
- VPN-only routing
- private Kubernetes service/network policy
- an authenticated reverse proxy
With the flag enabled, Ouroboros still warns when saving a non-localhost bind
without `OUROBOROS_NETWORK_PASSWORD`, but the Settings UI no longer blocks
ordinary settings saves such as API-key updates. Do not use this flag on an
open LAN or public port.

View file

@ -245,6 +245,23 @@ Before every commit, verify the following:
All platform-specific code **MUST** go through `ouroboros/platform_layer.py`.
### Shared State-File Helpers
Durable JSON state files should use the SSOT helpers in `ouroboros/utils.py`:
`atomic_write_json(path, payload, trailing_newline=False, fsync=False)` for
write-then-rename persistence and `read_json_dict(path)` for dict-shaped JSON
reads. Lockfile acquisition should go through
`platform_layer.acquire_exclusive_file_lock` /
`release_exclusive_file_lock` rather than reimplementing `O_CREAT|O_EXCL`
loops in feature modules.
Narrow exceptions are allowed only when the file's contract is not JSON-object
state or intentionally has extra durability semantics: `supervisor/state.py`
keeps `atomic_write_text` for mirrored `state.json` / `state.last_good.json`
text writes, and `ouroboros/config.py` keeps its settings-file lock because the
settings path is bootstrapped before broader runtime helpers should depend on
settings state.
### What counts as platform-specific
- Direct use of: `os.kill`, `os.setsid`, `os.killpg`, `os.getpgid`, `signal.SIGKILL`, `signal.SIGTERM`

View file

@ -9,13 +9,13 @@ from __future__ import annotations
import json
import logging
import os
import pathlib
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from ouroboros.utils import atomic_write_json
try:
from a2a.server.tasks import TaskStore
from a2a.types import Task
@ -73,17 +73,7 @@ class FileTaskStore(TaskStore):
async def save(self, task: Task, context=None) -> None:
path = self._task_path(task.id)
data = task.model_dump(mode="json", exclude_none=True)
content = json.dumps(data, ensure_ascii=False, indent=2)
# Atomic write: tmp -> rename
tmp = path.with_name(f".{path.name}.tmp.{uuid.uuid4().hex[:8]}")
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
try:
os.write(fd, content.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
os.replace(str(tmp), str(path))
atomic_write_json(path, data, fsync=True)
async def delete(self, task_id: str, context=None) -> None:
path = self._task_path(task_id)

View file

@ -16,7 +16,7 @@ from typing import Optional
from ouroboros.platform_layer import pid_lock_acquire as _compat_pid_lock_acquire
from ouroboros.platform_layer import pid_lock_release as _compat_pid_lock_release
from ouroboros.provider_models import migrate_model_value
from ouroboros.provider_models import compute_direct_review_models_fallback, migrate_model_value
# ---------------------------------------------------------------------------
@ -306,23 +306,13 @@ def direct_provider_review_models_fallback(provider: str) -> list[str]:
os.environ.get("OUROBOROS_MODEL", SETTINGS_DEFAULTS["OUROBOROS_MODEL"]) or ""
).strip()
main_model = migrate_model_value(provider, main_model)
provider_prefix = f"{provider}::"
if not main_model.startswith(provider_prefix):
return []
from ouroboros.provider_models import (
OPENAI_DIRECT_DEFAULTS, ANTHROPIC_DIRECT_DEFAULTS,
)
_defaults = {
"openai": OPENAI_DIRECT_DEFAULTS,
"anthropic": ANTHROPIC_DIRECT_DEFAULTS,
}.get(provider, {})
user_light_raw = str(os.environ.get("OUROBOROS_MODEL_LIGHT", "") or "").strip()
user_light = migrate_model_value(provider, user_light_raw) if user_light_raw else ""
default_light = migrate_model_value(provider, _defaults.get("light", ""))
light_slot = user_light if user_light.startswith(provider_prefix) else default_light
if light_slot and light_slot != main_model:
return [main_model, light_slot, light_slot]
return [main_model] * _DIRECT_PROVIDER_REVIEW_RUNS
return compute_direct_review_models_fallback(
provider,
main_model,
user_light_raw,
review_runs=_DIRECT_PROVIDER_REVIEW_RUNS,
)
def get_review_models() -> list[str]:

View file

@ -34,16 +34,13 @@ Schema (v1)::
from __future__ import annotations
import json
import logging
import os
import pathlib
import threading
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from ouroboros.skill_loader import skill_state_dir
from ouroboros.utils import atomic_write_json, read_json_dict
log = logging.getLogger(__name__)
@ -52,26 +49,6 @@ _SCHEMA_VERSION = 1
PROVENANCE_FILENAME = "clawhub.json"
def _atomic_write_json(path: pathlib.Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_name = (
f".{path.name}.tmp.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex[:8]}"
)
tmp = path.with_name(tmp_name)
try:
tmp.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(tmp, path)
except Exception:
try:
tmp.unlink()
except OSError:
pass
raise
def write_provenance(
drive_root: pathlib.Path,
skill_name: str,
@ -91,7 +68,7 @@ def write_provenance(
now_iso = datetime.now(timezone.utc).isoformat()
payload.setdefault("installed_at", now_iso)
payload["updated_at"] = now_iso
_atomic_write_json(target, payload)
atomic_write_json(target, payload, trailing_newline=True)
return target
@ -104,14 +81,7 @@ def read_provenance(
target = state_dir / PROVENANCE_FILENAME
if not target.is_file():
return None
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
log.warning("Failed to parse provenance file %s", target, exc_info=True)
return None
if not isinstance(data, dict):
return None
return data
return read_json_dict(target)
def delete_provenance(drive_root: pathlib.Path, skill_name: str) -> None:

View file

@ -14,6 +14,7 @@ import platform
import signal
import subprocess
import sys
import time
from typing import Any, List, Optional
log = logging.getLogger(__name__)
@ -46,6 +47,68 @@ def is_container_env() -> bool:
return False
def acquire_exclusive_file_lock(
lock_path: pathlib.Path,
*,
timeout_sec: float = 4.0,
stale_sec: float = 90.0,
metadata: str = "",
poll_sec: float = 0.05,
) -> Optional[int]:
"""Acquire a portable lockfile using O_EXCL and return its file descriptor."""
lock_path = pathlib.Path(lock_path)
lock_path.parent.mkdir(parents=True, exist_ok=True)
started = time.time()
while (time.time() - started) < timeout_sec:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
text = metadata or f"pid={os.getpid()} ts={time.time()}\n"
os.write(fd, text.encode("utf-8"))
except Exception:
log.debug("Failed to write lock metadata to %s", lock_path, exc_info=True)
return fd
except (FileExistsError, PermissionError):
try:
age = time.time() - lock_path.stat().st_mtime
if age > stale_sec:
lock_path.unlink()
continue
except Exception:
log.debug("Failed to inspect/remove stale lock %s", lock_path, exc_info=True)
time.sleep(poll_sec)
except Exception:
log.warning("Failed to acquire lock at %s", lock_path, exc_info=True)
break
return None
def release_exclusive_file_lock(lock_path: pathlib.Path, lock_fd: Optional[int]) -> None:
"""Release a lock acquired by :func:`acquire_exclusive_file_lock`."""
lock_path = pathlib.Path(lock_path)
if lock_fd is None:
return
try:
os.close(lock_fd)
except Exception:
log.debug("Failed to close lock fd %s for %s", lock_fd, lock_path, exc_info=True)
try:
if lock_path.exists():
lock_path.unlink()
except Exception:
log.debug("Failed to unlink lock file %s", lock_path, exc_info=True)
def unlink_lockfile(lock_path: pathlib.Path) -> None:
"""Best-effort cleanup for path-only locks whose fd was closed after acquire."""
lock_path = pathlib.Path(lock_path)
try:
if lock_path.exists():
lock_path.unlink()
except Exception:
log.debug("Failed to unlink lock file %s", lock_path, exc_info=True)
def open_path_external(path: pathlib.Path) -> None:
"""Open a local path with the platform default application."""

View file

@ -23,6 +23,11 @@ ANTHROPIC_DIRECT_DEFAULTS = {
"fallback": "anthropic::claude-sonnet-4-6",
}
_DIRECT_PROVIDER_DEFAULTS = {
"openai": OPENAI_DIRECT_DEFAULTS,
"anthropic": ANTHROPIC_DIRECT_DEFAULTS,
}
_ANTHROPIC_MODEL_ALIASES = {
"claude-opus-4.6": "claude-opus-4-6",
"claude-opus-" + "4.7": "claude-opus-4-6",
@ -50,6 +55,32 @@ def migrate_model_value(provider: str, value: str) -> str:
return text
def compute_direct_review_models_fallback(
provider: str,
main_model: str,
light_model: str = "",
*,
review_runs: int = 3,
) -> list[str]:
"""Return direct-provider review fallback preserving commit-triad shape.
The quorum-safe shape is ``[main, light, light]`` when main/light are
distinct provider-prefixed lanes; otherwise it degrades to ``[main] * N``.
"""
if provider not in _DIRECT_PROVIDER_DEFAULTS:
return []
provider_prefix = f"{provider}::"
main = migrate_model_value(provider, main_model)
if not main.startswith(provider_prefix):
return []
light = migrate_model_value(provider, light_model) if light_model else ""
default_light = migrate_model_value(provider, _DIRECT_PROVIDER_DEFAULTS[provider].get("light", ""))
light_slot = light if light.startswith(provider_prefix) else default_light
if light_slot and light_slot != main:
return [main, light_slot, light_slot]
return [main] * int(review_runs or 3)
def normalize_model_identity(model: str) -> str:
text = str(model or "").strip()
if text.endswith(" (local)"):

View file

@ -21,16 +21,16 @@ import json
import logging
import os
import pathlib
import time
import uuid
from dataclasses import asdict, dataclass, field
import re
from typing import Any, Callable, Dict, List, Optional
from ouroboros.utils import (
atomic_write_json,
truncate_review_artifact as _truncate_review_artifact,
truncate_review_reason as _truncate_review_reason,
)
from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock
log = logging.getLogger(__name__)
@ -1407,9 +1407,7 @@ def _save_state_unlocked(drive_root: pathlib.Path, state: AdvisoryReviewState) -
"last_stale_repo_key": state.last_stale_repo_key,
"saved_at": _utc_now(),
}
tmp = path.with_name(f".{path.name}.tmp.{uuid.uuid4().hex}")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
atomic_write_json(path, data)
def save_state(drive_root: pathlib.Path, state: AdvisoryReviewState) -> None:
@ -1450,44 +1448,17 @@ def acquire_review_state_lock(
stale_sec: float = 90.0,
) -> Optional[int]:
lock_path = drive_root / _LOCK_RELPATH
lock_path.parent.mkdir(parents=True, exist_ok=True)
started = time.time()
while (time.time() - started) < timeout_sec:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
meta = f"pid={os.getpid()} ts={_utc_now()}\n".encode("utf-8")
try:
os.write(fd, meta)
except Exception:
log.debug("Failed to write review-state lock metadata", exc_info=True)
return fd
except (FileExistsError, PermissionError):
try:
age = time.time() - lock_path.stat().st_mtime
if age > stale_sec:
lock_path.unlink()
continue
except Exception:
log.debug("Failed to inspect/remove stale review-state lock", exc_info=True)
time.sleep(0.05)
except Exception:
log.warning("Failed to acquire review-state lock at %s", lock_path, exc_info=True)
break
return None
return acquire_exclusive_file_lock(
lock_path,
timeout_sec=timeout_sec,
stale_sec=stale_sec,
metadata=f"pid={os.getpid()} ts={_utc_now()}\n",
)
def release_review_state_lock(drive_root: pathlib.Path, lock_fd: Optional[int]) -> None:
lock_path = drive_root / _LOCK_RELPATH
if lock_fd is not None:
try:
os.close(lock_fd)
except Exception:
log.debug("Failed to close review-state lock fd", exc_info=True)
try:
if lock_path.exists():
lock_path.unlink()
except Exception:
log.debug("Failed to unlink review-state lock", exc_info=True)
release_exclusive_file_lock(lock_path, lock_fd)
# --- Snapshot hash / repo identity ---

View file

@ -9,9 +9,10 @@ from typing import Awaitable, Callable
from ouroboros.provider_models import (
ANTHROPIC_DIRECT_DEFAULTS,
OPENAI_DIRECT_DEFAULTS,
compute_direct_review_models_fallback,
migrate_model_value,
)
from ouroboros.config import SETTINGS_DEFAULTS
from ouroboros.config import SETTINGS_DEFAULTS, _DIRECT_PROVIDER_REVIEW_RUNS
_DIRECT_PROVIDER_AUTO_DEFAULTS = {
@ -36,7 +37,6 @@ _DIRECT_PROVIDER_LEGACY_DEFAULTS = {
"anthropic": {},
}
_ALL_MODEL_SLOT_KEYS = tuple(_DIRECT_PROVIDER_AUTO_DEFAULTS["openai"].keys())
_DIRECT_PROVIDER_REVIEW_RUNS = 3
_SCOPE_REVIEW_LEGACY_DEFAULTS = frozenset({
"",
"anthropic/claude-opus-4.6",
@ -142,42 +142,13 @@ def _normalize_direct_review_models(settings: dict, provider: str) -> str:
has_foreign_models = any(not model.startswith(provider_prefix) for model in migrated_models)
if not migrated_models or len(migrated_models) < 2 or has_foreign_models:
# v4.39.0: seed a quorum-safe direct-provider fallback that still
# fills all three commit-triad slots: `[main, light, light]`
# (3 entries, 2 unique models).
#
# - Commit triad in `_run_unified_review` sees 3 reviewers — unchanged
# from the pre-v4.39.0 contract documented in DEVELOPMENT.md and
# ARCHITECTURE.md (`three models review the staged diff`).
# - plan_task in `_run_plan_review_async` dedupes to 2 unique reviewers
# and passes the v4.39.0 quorum gate.
# - The duplicated `light` slot is a minor redundancy in the commit
# triad's third vote — majority-vote already tolerates it, and the
# old fallback `[main] * 3` had even more duplication.
#
# `light_slot` is derived from the user's actual
# `OUROBOROS_MODEL_LIGHT` first (so a custom lane like
# `openai::o4-mini` is honoured); only when that setting is empty
# or points at a foreign-provider model do we fall back to the
# shipped `_DIRECT_PROVIDER_AUTO_DEFAULTS` light for this provider.
# If the resolved light still collapses to the same model as main
# (user explicitly overrode both lanes identically), degrade to the
# legacy `[main] * _DIRECT_PROVIDER_REVIEW_RUNS` shape — commit
# triad still works, `plan_task` emits its quorum-error hint.
user_light_raw = _setting_text(settings, "OUROBOROS_MODEL_LIGHT")
user_light = migrate_model_value(provider, user_light_raw) if user_light_raw else ""
provider_defaults = _DIRECT_PROVIDER_AUTO_DEFAULTS.get(provider, {})
default_light = migrate_model_value(
provider, provider_defaults.get("OUROBOROS_MODEL_LIGHT", "")
fallback = compute_direct_review_models_fallback(
provider,
main_model,
user_light_raw,
review_runs=_DIRECT_PROVIDER_REVIEW_RUNS,
)
if user_light and user_light.startswith(provider_prefix):
light_slot = user_light
else:
light_slot = default_light
if light_slot and light_slot != main_model:
fallback = [main_model, light_slot, light_slot]
else:
fallback = [main_model] * _DIRECT_PROVIDER_REVIEW_RUNS
return _serialize_model_list(fallback)
return _serialize_model_list(migrated_models)

View file

@ -39,11 +39,8 @@ from __future__ import annotations
import hashlib
import json
import logging
import os
import pathlib
import tempfile
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
@ -54,6 +51,7 @@ from ouroboros.contracts.skill_manifest import (
parse_skill_manifest_text,
)
from ouroboros.contracts.plugin_api import FORBIDDEN_SKILL_SETTINGS
from ouroboros.utils import atomic_write_json, read_json_dict
log = logging.getLogger(__name__)
@ -601,48 +599,8 @@ def compute_content_hash(
# ---------------------------------------------------------------------------
def _atomic_write_json(path: pathlib.Path, payload: Dict[str, Any]) -> None:
"""Atomically write ``payload`` as JSON to ``path``.
Uses a unique temp filename (pid + thread id + uuid4 fragment) so
two concurrent writes to the same durable-state file — whether
from different threads inside one process or from a reviewer tool
racing with a ``toggle_skill`` — cannot stomp each other's temp
files or hit ``FileNotFoundError`` in ``os.replace``.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp_name = (
f".{path.name}.tmp.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex[:8]}"
)
tmp = path.with_name(tmp_name)
try:
tmp.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
os.replace(tmp, path)
except Exception:
# Best-effort cleanup of a stale temp; os.replace failure shouldn't
# leave dot-turds sitting next to the real file.
try:
tmp.unlink()
except OSError:
pass
raise
def _read_json(path: pathlib.Path) -> Optional[Dict[str, Any]]:
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
log.warning("Failed to parse skill state file %s", path, exc_info=True)
return None
return data if isinstance(data, dict) else None
def load_enabled(drive_root: pathlib.Path, name: str) -> bool:
state = _read_json(skill_state_dir(drive_root, name) / "enabled.json")
state = read_json_dict(skill_state_dir(drive_root, name) / "enabled.json")
if not isinstance(state, dict):
return False
enabled = state.get("enabled")
@ -650,7 +608,7 @@ def load_enabled(drive_root: pathlib.Path, name: str) -> bool:
def save_enabled(drive_root: pathlib.Path, name: str, enabled: bool) -> None:
_atomic_write_json(
atomic_write_json(
skill_state_dir(drive_root, name) / "enabled.json",
{
"enabled": bool(enabled),
@ -660,7 +618,7 @@ def save_enabled(drive_root: pathlib.Path, name: str, enabled: bool) -> None:
def load_review_state(drive_root: pathlib.Path, name: str) -> SkillReviewState:
data = _read_json(skill_state_dir(drive_root, name) / "review.json")
data = read_json_dict(skill_state_dir(drive_root, name) / "review.json")
if not isinstance(data, dict):
return SkillReviewState()
raw_status = str(data.get("status") or _REVIEW_STATUS_PENDING).lower()
@ -704,7 +662,7 @@ def save_review_state(
name: str,
review: SkillReviewState,
) -> None:
_atomic_write_json(
atomic_write_json(
skill_state_dir(drive_root, name) / "review.json",
review.to_dict(),
)
@ -722,7 +680,7 @@ def requested_core_setting_keys(env_keys: List[str]) -> List[str]:
def load_skill_grants(drive_root: pathlib.Path, name: str) -> Dict[str, Any]:
data = _read_json(skill_state_dir(drive_root, name) / GRANTS_FILENAME)
data = read_json_dict(skill_state_dir(drive_root, name) / GRANTS_FILENAME)
if not isinstance(data, dict):
return {"granted_keys": [], "updated_at": ""}
keys = []
@ -778,7 +736,7 @@ def save_skill_grants(
key = str(raw_key or "").strip().upper()
if key and key in allowed and key not in merged:
merged.append(key)
_atomic_write_json(
atomic_write_json(
skill_state_dir(drive_root, name) / GRANTS_FILENAME,
{
"granted_keys": merged,

View file

@ -34,7 +34,7 @@ from ouroboros.skill_loader import (
skill_state_dir,
)
from ouroboros.skill_review import SkillReviewOutcome, review_skill as _default_review_skill
from ouroboros.utils import append_jsonl, utc_now_iso
from ouroboros.utils import append_jsonl, atomic_write_json, read_json_dict, utc_now_iso
log = logging.getLogger(__name__)
@ -54,18 +54,11 @@ def _events_path(drive_root: pathlib.Path) -> pathlib.Path:
def _write_json_atomic(path: pathlib.Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp.replace(path)
atomic_write_json(path, payload, trailing_newline=True)
def _read_review_job(path: pathlib.Path) -> Dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
return read_json_dict(path) or {}
def _pid_alive(pid: int) -> bool:

View file

@ -8,7 +8,7 @@ import pathlib
from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from ouroboros.utils import utc_now_iso
from ouroboros.utils import atomic_write_json, utc_now_iso
_CONTINUATION_DIR_RELPATH = "state/review_continuations"
_CORRUPT_DIR_NAME = "corrupt"
@ -113,9 +113,7 @@ def save_review_continuation(
continuation.updated_ts = now_ts
payload = asdict(continuation)
tmp_path = path.parent / f"{task_id}.json.tmp"
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp_path, path)
atomic_write_json(path, payload)
return continuation

View file

@ -2,12 +2,10 @@
from __future__ import annotations
import json
import os
import pathlib
from typing import Any, Dict, List, Optional
from ouroboros.utils import utc_now_iso
from ouroboros.utils import atomic_write_json, read_json_dict, utc_now_iso
STATUS_REQUESTED = "requested"
STATUS_SCHEDULED = "scheduled"
@ -31,12 +29,7 @@ def task_result_path(drive_root: Any, task_id: str) -> pathlib.Path:
def load_task_result(drive_root: Any, task_id: str) -> Optional[Dict[str, Any]]:
path = task_result_path(drive_root, task_id)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return read_json_dict(path)
def list_task_results(
@ -47,9 +40,8 @@ def list_task_results(
wanted = {str(item) for item in list(statuses or []) if str(item).strip()}
results: List[Dict[str, Any]] = []
for path in sorted(task_results_dir(drive_root).glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
data = read_json_dict(path)
if data is None:
continue
if wanted and str(data.get("status") or "") not in wanted:
continue
@ -75,7 +67,5 @@ def write_task_result(
"ts": ts,
}
tmp_path = path.parent / f"{task_id}.json.tmp"
tmp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp_path, path)
atomic_write_json(path, payload)
return payload

View file

@ -50,6 +50,7 @@ from ouroboros.tools.review_helpers import (
CRITICAL_FINDING_CALIBRATION,
get_advisory_runtime_diagnostics as _get_runtime_diagnostics,
format_advisory_sdk_error as _format_advisory_error,
load_governance_doc,
normalize_reviewer_obligation_id,
strip_obligation_suffix,
_ANTI_THRASHING_RULE_VERDICT,
@ -123,13 +124,7 @@ def _emit_advisory_usage(
_ADVISORY_PROMPT_MAX_CHARS = 1_600_000 # ~400K tokens; non-blocking skip when exceeded
def _load_doc(repo_dir: pathlib.Path, relpath: str, fallback: str = "") -> str:
try:
p = repo_dir / relpath
if p.is_file():
return p.read_text(encoding="utf-8")
except Exception:
pass
return fallback
return load_governance_doc(repo_dir, relpath, on_missing="placeholder", fallback=fallback)
def _get_staged_diff(

View file

@ -26,6 +26,7 @@ from ouroboros.runtime_mode_policy import (
protected_paths_in,
protected_write_block_message,
)
from ouroboros.platform_layer import acquire_exclusive_file_lock, unlink_lockfile
from ouroboros.tools.registry import ToolContext, ToolEntry
from ouroboros.tools.commit_gate import (
_check_advisory_freshness,
@ -714,34 +715,24 @@ def _acquire_git_lock(ctx: ToolContext, timeout_sec: int = 120) -> pathlib.Path:
lock_dir = ctx.drive_path("locks")
lock_dir.mkdir(parents=True, exist_ok=True)
lock_path = lock_dir / "git.lock"
stale_sec = 600
deadline = time.time() + timeout_sec
while time.time() < deadline:
if lock_path.exists():
try:
age = time.time() - lock_path.stat().st_mtime
if age > stale_sec:
lock_path.unlink()
continue
except (FileNotFoundError, OSError):
pass
fd = acquire_exclusive_file_lock(
lock_path,
timeout_sec=float(timeout_sec),
stale_sec=600.0,
metadata=f"locked_at={utc_now_iso()}\n",
poll_sec=0.5,
)
if fd is not None:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
os.write(fd, f"locked_at={utc_now_iso()}\n".encode("utf-8"))
finally:
os.close(fd)
return lock_path
except FileExistsError:
time.sleep(0.5)
os.close(fd)
except OSError:
pass
return lock_path
raise TimeoutError(f"Git lock not acquired within {timeout_sec}s: {lock_path}")
def _release_git_lock(lock_path: pathlib.Path) -> None:
try:
lock_path.unlink()
except FileNotFoundError:
pass
unlink_lockfile(lock_path)
MAX_TEST_OUTPUT = 8000
_consecutive_test_failures: int = 0

View file

@ -32,6 +32,7 @@ from ouroboros.tools.registry import ToolContext, ToolEntry
from ouroboros.tools.review_helpers import (
build_full_repo_pack,
build_head_snapshot_section,
load_governance_doc,
load_checklist_section,
)
from ouroboros.utils import estimate_tokens
@ -895,16 +896,4 @@ def _load_bible(repo_dir: Path) -> str:
def _load_doc(repo_dir: Path, rel_path: str) -> str:
"""Load a documentation file relative to the repo root.
Returns file contents on success. On failure, returns an explicit omission
note so the reviewer knows that context is missing rather than silently
receiving an empty string.
"""
p = repo_dir / rel_path
try:
if p.is_file():
return p.read_text(encoding="utf-8")
return f"[⚠️ OMISSION: {rel_path} not found at {p}]"
except Exception as e:
return f"[⚠️ OMISSION: {rel_path} could not be loaded ({p}): {e}]"
return load_governance_doc(repo_dir, rel_path, on_missing="explicit")

View file

@ -31,6 +31,31 @@ logger = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
def load_governance_doc(
repo_dir: Path,
rel_path: str,
*,
on_missing: str = "explicit",
fallback: str = "",
) -> str:
"""Load a governance/review document relative to ``repo_dir`` with explicit miss policy."""
path = Path(repo_dir) / rel_path
try:
if path.is_file():
return path.read_text(encoding="utf-8")
except Exception as exc:
if on_missing == "silent":
return fallback
if on_missing == "placeholder":
return fallback
return f"[⚠️ OMISSION: {rel_path} could not be loaded ({path}): {exc}]"
if on_missing == "silent":
return fallback
if on_missing == "placeholder":
return fallback if fallback else f"({rel_path} not found)"
return f"[⚠️ OMISSION: {rel_path} not found at {path}]"
BINARY_EXTENSIONS = frozenset({
# Compiled / archive
".so", ".dylib", ".dll", ".pyc", ".whl", ".egg",

View file

@ -37,6 +37,7 @@ from ouroboros.tools.review_helpers import (
_SENSITIVE_NAMES,
format_obligation_excerpt,
format_prompt_code_block,
load_governance_doc,
normalize_reviewer_items,
_ANTI_THRASHING_RULE_VERDICT,
_ANTI_THRASHING_RULE_ITEM_NAME,
@ -145,13 +146,7 @@ _CURRENT_TOUCHED_CONTEXT_SKIP_PREFIXES = (
def _load_doc(repo_dir: pathlib.Path, rel_path: str) -> str:
try:
p = repo_dir / rel_path
if p.is_file():
return p.read_text(encoding="utf-8")
except Exception:
pass
return f"({rel_path} not found)"
return load_governance_doc(repo_dir, rel_path, on_missing="placeholder")
def _load_dev_guide(repo_dir: pathlib.Path) -> str:

View file

@ -14,7 +14,9 @@ import logging
import os
import pathlib
import subprocess
import threading
import time
import uuid
from typing import Any, Callable, Dict, List, Optional
log = logging.getLogger(__name__)
@ -59,6 +61,55 @@ def write_text(path: pathlib.Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def atomic_write_json(
path: pathlib.Path,
payload: Dict[str, Any],
*,
trailing_newline: bool = False,
fsync: bool = False,
) -> None:
"""Atomically persist a JSON object using a unique sibling temp file."""
path = pathlib.Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_name = (
f".{path.name}.tmp.{os.getpid()}.{threading.get_ident()}.{uuid.uuid4().hex[:8]}"
)
tmp = path.with_name(tmp_name)
content = json.dumps(payload, ensure_ascii=False, indent=2)
if trailing_newline:
content += "\n"
try:
if fsync:
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
try:
os.write(fd, content.encode("utf-8"))
os.fsync(fd)
finally:
os.close(fd)
else:
tmp.write_text(content, encoding="utf-8")
os.replace(tmp, path)
except Exception:
try:
tmp.unlink()
except OSError:
pass
raise
def read_json_dict(path: pathlib.Path) -> Optional[Dict[str, Any]]:
"""Return a JSON object from ``path`` or ``None`` when absent/invalid."""
path = pathlib.Path(path)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
log.warning("Failed to parse JSON file %s", path, exc_info=True)
return None
return data if isinstance(data, dict) else None
def append_jsonl(path: pathlib.Path, obj: Dict[str, Any]) -> bool:
"""Append a JSON object as a line to a JSONL file (concurrent-safe).

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ouroboros"
version = "5.8.3rc1"
version = "5.8.3rc2"
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
readme = "README.md"
license = {text = "MIT"}

View file

@ -121,6 +121,11 @@ def _is_wildcard_host(host: str) -> bool:
return host in _WILDCARD_HOSTS
def _trust_nonlocal_bind_without_password_enabled() -> bool:
raw = os.environ.get("OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD", "")
return str(raw or "").strip().lower() in {"1", "true", "yes", "on"}
from ouroboros.platform_layer import is_container_env
@ -1229,6 +1234,7 @@ async def api_settings_post(request: Request) -> JSONResponse:
from ouroboros.server_auth import is_loopback_host
desired_host = str(current.get("OUROBOROS_SERVER_HOST") or "").strip()
desired_password = str(current.get("OUROBOROS_NETWORK_PASSWORD") or "").strip()
trust_unauth = _trust_nonlocal_bind_without_password_enabled()
allowed_saved_hosts = {"", "127.0.0.1", "localhost", "::1", "[::1]", "0.0.0.0", "::", "[::]"}
if desired_host and desired_host not in allowed_saved_hosts:
return JSONResponse(
@ -1242,7 +1248,7 @@ async def api_settings_post(request: Request) -> JSONResponse:
},
status_code=400,
)
if desired_host and not is_loopback_host(desired_host) and not desired_password:
if desired_host and not is_loopback_host(desired_host) and not desired_password and not trust_unauth:
return JSONResponse(
{
"error": (
@ -1264,6 +1270,7 @@ async def api_settings_post(request: Request) -> JSONResponse:
and not is_loopback_host(current_effective_host)
and old_password
and not desired_password
and not trust_unauth
):
return JSONResponse(
{
@ -1358,10 +1365,17 @@ async def api_settings_post(request: Request) -> JSONResponse:
desired_host = str(current.get("OUROBOROS_SERVER_HOST") or "").strip()
desired_password = str(current.get("OUROBOROS_NETWORK_PASSWORD") or "").strip()
if desired_host and not is_loopback_host(desired_host) and not desired_password:
warnings.append(
"Server Bind Host is non-localhost and Network Password is empty; "
"after restart the app will be reachable on the network without a password."
)
if _trust_nonlocal_bind_without_password_enabled():
warnings.append(
"OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1 allows this "
"non-localhost bind without Ouroboros's internal Network Password. "
"Use only behind ingress auth, VPN, private networking, or an auth proxy."
)
else:
warnings.append(
"Server Bind Host is non-localhost and Network Password is empty; "
"after restart the app will be reachable on the network without a password."
)
except Exception:
pass
_repo_slug = current.get("GITHUB_REPO", "")

View file

@ -15,6 +15,8 @@ import time
import uuid
from typing import Any, Dict, Optional
from ouroboros.platform_layer import acquire_exclusive_file_lock, release_exclusive_file_lock
log = logging.getLogger(__name__)
@ -72,47 +74,16 @@ def json_load_file(path: pathlib.Path) -> Optional[Dict[str, Any]]:
def acquire_file_lock(lock_path: pathlib.Path, timeout_sec: float = 4.0,
stale_sec: float = 90.0) -> Optional[int]:
lock_path.parent.mkdir(parents=True, exist_ok=True)
started = time.time()
while (time.time() - started) < timeout_sec:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
try:
os.write(fd, f"pid={os.getpid()} ts={datetime.datetime.now(datetime.timezone.utc).isoformat()}\n".encode("utf-8"))
except Exception:
log.debug(f"Failed to write lock metadata to {lock_path}", exc_info=True)
pass
return fd
except FileExistsError:
try:
age = time.time() - lock_path.stat().st_mtime
if age > stale_sec:
lock_path.unlink()
continue
except Exception:
log.debug(f"Failed to check/remove stale lock at {lock_path}", exc_info=True)
pass
time.sleep(0.05)
except Exception:
log.warning(f"Failed to acquire lock at {lock_path}", exc_info=True)
break
return None
return acquire_exclusive_file_lock(
lock_path,
timeout_sec=timeout_sec,
stale_sec=stale_sec,
metadata=f"pid={os.getpid()} ts={datetime.datetime.now(datetime.timezone.utc).isoformat()}\n",
)
def release_file_lock(lock_path: pathlib.Path, lock_fd: Optional[int]) -> None:
if lock_fd is None:
return
try:
os.close(lock_fd)
except Exception:
log.debug(f"Failed to close lock fd {lock_fd} for {lock_path}", exc_info=True)
pass
try:
if lock_path.exists():
lock_path.unlink()
except Exception:
log.debug(f"Failed to unlink lock file {lock_path}", exc_info=True)
pass
release_exclusive_file_lock(lock_path, lock_fd)
# Re-export append_jsonl from ouroboros.utils (single source of truth)

View file

@ -0,0 +1,37 @@
import os
from ouroboros.platform_layer import (
acquire_exclusive_file_lock,
release_exclusive_file_lock,
unlink_lockfile,
)
def test_release_without_fd_does_not_unlink_existing_lock(tmp_path):
lock_path = tmp_path / "state.lock"
lock_path.write_text("owned elsewhere", encoding="utf-8")
release_exclusive_file_lock(lock_path, None)
assert lock_path.read_text(encoding="utf-8") == "owned elsewhere"
def test_release_with_fd_unlinks_owned_lock(tmp_path):
lock_path = tmp_path / "state.lock"
fd = acquire_exclusive_file_lock(lock_path, metadata="owned\n")
assert fd is not None
release_exclusive_file_lock(lock_path, fd)
assert not lock_path.exists()
def test_path_only_git_lock_cleanup_remains_available(tmp_path):
lock_path = tmp_path / "git.lock"
fd = acquire_exclusive_file_lock(lock_path, metadata="owned\n")
assert fd is not None
os.close(fd)
unlink_lockfile(lock_path)
assert not lock_path.exists()

View file

@ -226,6 +226,55 @@ def test_api_settings_post_requires_password_for_nonloopback_bind(monkeypatch, t
assert "requires a Network Password" in payload["error"]
def test_api_settings_post_trusted_nonlocal_bind_allows_normal_save(monkeypatch, tmp_path):
monkeypatch.setenv("OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD", "1")
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-existing")
server_module = _reload_server(monkeypatch, tmp_path)
class _Request:
async def json(self):
return {
"OUROBOROS_SERVER_HOST": "0.0.0.0",
"OUROBOROS_NETWORK_PASSWORD": "",
"OPENROUTER_API_KEY": "sk-updated",
}
response = asyncio.run(server_module.api_settings_post(_Request()))
payload = json.loads(response.body.decode("utf-8"))
assert response.status_code == 200
assert payload["status"] == "saved"
assert any(
"OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD=1" in warning
for warning in payload.get("warnings", [])
)
def test_api_settings_post_cannot_enable_trusted_nonlocal_bind_via_body(monkeypatch, tmp_path):
server_module = _reload_server(monkeypatch, tmp_path)
class _TrustRequest:
async def json(self):
return {"OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD": True}
response = asyncio.run(server_module.api_settings_post(_TrustRequest()))
assert response.status_code == 200
assert not os.environ.get("OUROBOROS_TRUST_NONLOCAL_BIND_WITHOUT_PASSWORD")
class _BindRequest:
async def json(self):
return {
"OUROBOROS_SERVER_HOST": "0.0.0.0",
"OUROBOROS_NETWORK_PASSWORD": "",
}
response = asyncio.run(server_module.api_settings_post(_BindRequest()))
payload = json.loads(response.body.decode("utf-8"))
assert response.status_code == 400
assert "requires a Network Password" in payload["error"]
def test_api_settings_post_rejects_specific_lan_host(monkeypatch, tmp_path):
server_module = _reload_server(monkeypatch, tmp_path)