mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-19 10:04:16 +00:00
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:
parent
5acde5c398
commit
409a856f7e
27 changed files with 414 additions and 318 deletions
15
README.md
15
README.md
|
|
@ -6,7 +6,7 @@
|
|||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](https://github.com/joi-lab/ouroboros-desktop/releases)
|
||||
[](VERSION)
|
||||
[](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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
5.8.3-rc.1
|
||||
5.8.3-rc.2
|
||||
|
|
|
|||
|
|
@ -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
27
docs/DEPLOYMENT.md
Normal 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.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"):
|
||||
|
|
|
|||
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
24
server.py
24
server.py
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
37
tests/test_lockfile_helpers.py
Normal file
37
tests/test_lockfile_helpers.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue