diff --git a/README.md b/README.md index c659b7cab..14553835b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [![Linux](https://img.shields.io/badge/Linux-x86__64-orange.svg)](https://ouroboros-agent.ai/install/#linux) [![Windows](https://img.shields.io/badge/Windows-x64-blue.svg)][download-windows-x64] [![OuroborosHub](https://img.shields.io/badge/OuroborosHub-skills%20marketplace-8A2BE2.svg)](https://github.com/razzant/OuroborosHub) -[![Version 7.5.0](https://img.shields.io/badge/version-7.5.0-green.svg)](VERSION) +[![Version 7.5.1](https://img.shields.io/badge/version-7.5.1-green.svg)](VERSION) Ouroboros is an open-source, general-purpose AI agent whose identity, durable memory, and history continue across tasks and restarts. It works on external projects, coordinates a live swarm of specialist agents, and can rewrite the implementation it runs on, including its code, architecture, prompts, tools, and dependencies. Reflection can also change how it understands itself without severing that continuity. @@ -66,13 +66,13 @@ The desktop packages already contain an optional CLI installer. On macOS, after -[download-macos-arm64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/Ouroboros-7.5.0.dmg -[download-windows-x64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/Ouroboros-7.5.0-windows-x64.zip -[download-linux-deb-amd64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/ouroboros_7.5.0_amd64.deb -[download-linux-rpm-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/ouroboros-7.5.0-1.x86_64.rpm -[download-linux-rpm-red80-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/ouroboros-7.5.0-1.red80.x86_64.rpm -[download-linux-appimage-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/Ouroboros-7.5.0-linux-x86_64.AppImage -[download-linux-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.0/Ouroboros-7.5.0-linux-x86_64.tar.gz +[download-macos-arm64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/Ouroboros-7.5.1.dmg +[download-windows-x64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/Ouroboros-7.5.1-windows-x64.zip +[download-linux-deb-amd64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/ouroboros_7.5.1_amd64.deb +[download-linux-rpm-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/ouroboros-7.5.1-1.x86_64.rpm +[download-linux-rpm-red80-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/ouroboros-7.5.1-1.red80.x86_64.rpm +[download-linux-appimage-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/Ouroboros-7.5.1-linux-x86_64.AppImage +[download-linux-x86_64]: https://github.com/razzant/ouroboros/releases/download/v7.5.1/Ouroboros-7.5.1-linux-x86_64.tar.gz Ouroboros bundles [Claudexor](https://github.com/razzant/claudexor) as its local execution layer for delegated coding and hosted-agent review. Ouroboros owns the task, memory, review, and final integration, while Claudexor runs the selected connected coding harness and returns durable execution evidence. [Explore Claudexor](https://claudexor.ai/). @@ -454,18 +454,18 @@ and the reason. | Version | Date | Description | |---------|------|-------------| +| 7.5.1 | 2026-09-26 | **fix: resumable work, planning dialogue and reliable results.** Changes since 7.4.11 preserve tasks through budget pauses, reviewer answers through follow-ups, child files and unread messages through cleanup, and unedited memory with its source history. Includes responsive owner controls, Presence recovery, Claudexor 3.16.0 live messaging, provider caching and Docker/TLS improvements, plus portable CI and Python 3.13 repairs. **Windows ordinary task-file, chat-media and result-ZIP downloads remain HTTP 503 (#1297).** | | 7.5.0 | 2026-09-26 | **feat: one self-contained Docker image and an owner extra-CA bundle for provider TLS (#1300).** Contributed by @komsikov and reworked in-branch: the image installs the locked Playwright browsers above the lock copy with BuildKit cache mounts (4.82 GB → 4.12 GB; the tag-only docker lanes exercise the shipped browsers), and `OUROBOROS_EXTRA_CA_BUNDLE` adds a PEM of extra CA certificates on top of certifi for every first-party provider call, with a content-addressed merged bundle so a rotated certificate takes effect on the next task (Settings → Advanced; `docs/DEPLOYMENT.md`). | | 7.4.11 | 2026-09-24 | **chore: ignore `graphify-out` and add a root `.dockerignore` (#1255).** Contributed by @komsikov; the Docker build context keeps secrets, host environments and local runtime/review state out of image layers while `.git`, `tests/` and sources stay in, pinned by `TestDockerignore`. | | 7.4.10 | 2026-09-24 | **fix: isolate candidate verification and run complete event-driven browser CI (#1215, #1143, #1084, #1112).** Tests, preflight and server children run on disposable HOME/data/projects/worktrees/Deliverables roots and never install dependencies into the operator's Python; a browser candidate is a faithful copy of the dirty tree (staged, unstaged, new, deleted, binary, mode) whose source HEAD, index and files are proven unchanged, with unsupported inputs and concurrent mutation refused explicitly and temporary trees retained until process teardown is proven. The complete existing `ui_browser` lane runs for every non-documentation PR and every `ouroboros` push through `--require-ui-browser` (no new cron); the startup history-audit child inherits the containment token; the isolation keeps the platform's default text encoding so Windows CI still catches cp1252 bugs. | | 7.4.9 | 2026-09-24 | **fix: truthful task cards and history (#1011, #1087, #1110, #1154, #1061, #931, #1073, #516, #869, #498).** A known outcome (Failed / Done with warnings) is the card's primary word while post-task work still runs, with `Finalizing…` secondary and the task name stable; a completed lifecycle with a failed outcome never reads as a clean completion. Money speaks one scoped carrier — `Cost unknown`, `Tracked: $X`, `up to $X`, an evidenced `$0.00` — with `cost unavailable` reserved for an unreadable ledger and a nested child's foreign rollup never replaced by its own zero. Cancellation states its recorded cause and proven parent relation; task diagnostics stay in the disclosure, worker events in Logs; the Project's final Main summary is durably owed, retried and deduplicated after completion, and the provider-outage terminal names its source and a human cause without extra model calls. Verified by focused, Node and real-backend browser journeys including a live Failed+Finalizing actor. | | 7.4.8 | 2026-09-24 | **chore: ignore `.devcontainer` (#1253).** A contributor's devcontainer folder no longer dirties the tree or the seed gate. | -| 7.4.7 | 2026-09-24 | **test: repair Windows repository-evidence fixtures.** Keep POSIX private-mode checks where supported and clear the read-only attribute before deliberately removing a fixture Git object; byte-retention and unavailable-source checks remain active on Windows. | | 7.4.0 | 2026-09-20 | **feat: add an honest desktop attention cue for live notifications.** The optional launcher bridge can raise the existing desktop window and request one platform system sound, returning explicit native/unsupported/window_only/unavailable facts; it does not claim Notification Center delivery, run after close, or add a tray/background process. Browser banners and in-app toasts remain the fallback, with capability text visible in Settings. Focused native, launcher and notification tests cover the seam. | | 7.3.0 | 2026-09-19 | **feat: notifications can pull you back to a question or a finished task while a client is running (#1122).** Settings → Appearance gains a notification block next to the theme: a master switch, the two required categories (a question or decision is waiting for you, a task finished or stopped), a model-chosen category for messages Ouroboros sends while it works, a separate off-by-default toggle for ordinary replies in Main, sound, an off-by-default show-the-text choice and a test notification. The choices are stored per client exactly like the appearance choice, never reach the server, and do not make the settings draft dirty. Delivery is page-level: a system banner where this client exposes one and permission is granted, otherwise the in-app surface plus one short tone, with the status line saying which surface you actually have; either way a click opens the source. The new `web/modules/notifications.js` keeps classification and the delivery gate pure over one live frame plus the stored preferences, and takes ONE subscription per client on the shared socket — not inside a chat instance, which dies with its room, so a Project the owner never opened still reaches him; `web/modules/chat.js` is untouched. Only live frames reach it, so a reload cannot re-notify without any stored notification state. A finished managed root is recognised on the shape it actually arrives in (a live `task_done` log frame) as well as the authored summary, and a conversation turn's own ending stays the ordinary-reply category instead of claiming a task finished; all of them collapse to one key per task. A child task never notifies the owner: it escalates to its parent, and lineage is read from the delegation facts frames carry because the terminal frame has none. Importance needs no new host field and no second model call: the existing proactive-message discriminator is the signal. Policy and its disclosed limits have one canonical home in `docs/DESIGN.md` §9 — including that each open window is its own client, that an event during a dropped socket never rings, and that no OS permission, Do Not Disturb or platform limit is bypassed. | | 7.2.0 | 2026-09-19 | **feat: Light, Dark and System appearance, review that reads the repository instead of a packed snapshot, and one fast local test battery.** The web UI gains a client-local Appearance choice — Light, Dark or System — applied before first paint in the app and the onboarding wizard and carried through in-page charts, diagrams and host-rendered widget charts, while framed skill widgets keep their own colours (#1107). With no saved choice the UI follows the system scheme, so an existing install on a light OS opens in Light until Dark is chosen; to remember the choice the desktop shell now keeps website data, including cookies, and a desktop app that only took in-app updates remembers it once the 7.2.0 installer has been installed. Project questions are one row in Main and become a card only while the task waits (#1106). Scope review retrieves what it needs from the repository rather than receiving one packed snapshot (#1043); plan review keeps paid feedback (#1051), acceptance review keeps its rework controls, final review text and explicit author completion (#1042, #1069, #1089), and system review mail no longer reopens accepted results (#1088). `python scripts/run_tests.py` is the one documented local battery: it runs the node lane first, then every default-lane test in one parallel run (#1101). First-run setup recovers subscription sign-in and model discovery (#1099), and Finish stays usable after a refused Main-reviewer recovery (#1100); delegated requests are stored by reference and keep pending recovery (#1067); terminal outcomes, usage accounting, cancellation provenance, task relay authorship and process environments are preserved end to end (#1054, #1064, #1083, #1098). Also: the managed Claudexor runtime moves to 3.12.4, an experimental Android host with optional release artifacts (#873), bounded Cowork Bench meter reads that preserve interrupted work (#1044), chat composer, Activity row and project-link fixes (#1063, #1095), clearer Presence delivery (#1053), subagent scheduling refusals (#1092), settings and helper-history reporting (#1090), and scope-review context logging from a community contribution (#752). | | 7.1.0 | 2026-09-17 | **feat: memory that understands people, Background Consciousness as an ordinary Main turn, and a chat that leads with narration.** Memory keeps an understanding of people in front of the mind through resident summaries, one memory shared by every room and honest consolidation (#910). Background Consciousness becomes an ordinary Main turn on an alarm clock (#988, closing the class behind #654). Chat blocks lead with narration while tool calls fold into one evidence row (#1007); task cards follow their work and treat acceptance review as advice to Ouroboros (#970); question pointers say whether an answer is wanted and show the recorded answer (#1025); the unified UI control system, source picker and model chooser name models and accounts truthfully (#768, #862, #890). Planning is asynchronous and Swarm tasks are admitted directly (#851); delegated work survives durable cancellation (#850); a task's own reviewer is never swept as its delegation (#1008); semantic duplicate vetoes leave subagent admission (#887); Presence profiles work in an owner-selected folder (#941). Prompt caches extend through an append-only acceptance observation and a shared Codex cache key per install and model (#929, #1015). Update letters include merged branch changes (#1003), the owned Claudexor daemon latches failed starts with a typed diagnosis, and the managed runtime advances to Claudexor 3.12.1 (#891, #962), alongside Windows, platform-installation and UI-smoke repairs. | | 7.0.0 | 2026-09-08 | **v7: a modular runtime with full ordinary-conversation tools, complete delegated inputs and visible owner dialogue.** Main and Project conversations retain their chosen tools and working folders; required owner waits preserve the live browser without occupying pooled execution capacity. Publish admission is visible in its intended chat, and GitHub/image/plan failures retain their actual causes. Planning advice stays optional; source-backed evidence, separate host notices and exact-workload test reuse preserve honest review without new approval machinery. Integrates the subscription model and account-role controls, owned startup/restart custody and cross-platform repairs, with Claudexor pinned to 3.10.1. | -Older releases are preserved in this repository's history. Rows 7.4.5, 6.114.0, 6.113.2 and 6.113.5 are retained in Git history. Older 6.x rows (including 6.113.1, 6.110.0, 6.109.0, 6.110.1, 6.108.1, 6.106.0, 6.101.1, 6.97.2, 6.105.0, 6.97.1, 6.97.0, 6.96.1, 6.96.0, 6.95.0, 6.94.0, 6.93.0, 6.92.1, 6.92.0, 6.91.1, 6.90.3, 6.91.0, 6.90.2, 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain in this file's git history, with release tags where present for historical versions. +Older releases are preserved in this repository's history. Rows 7.4.7, 7.4.5, 6.114.0, 6.113.2 and 6.113.5 are retained in Git history. Older 6.x rows (including 6.113.1, 6.110.0, 6.109.0, 6.110.1, 6.108.1, 6.106.0, 6.101.1, 6.97.2, 6.105.0, 6.97.1, 6.97.0, 6.96.1, 6.96.0, 6.95.0, 6.94.0, 6.93.0, 6.92.1, 6.92.0, 6.91.1, 6.90.3, 6.91.0, 6.90.2, 6.90.0, 6.87.5, 6.87.4, 6.87.3, 6.87.2, 6.84.0, 6.87.1, 6.83.0, 6.86.1, 6.81.1, 6.76.0, 6.75.0, 6.74.5, 6.74.4, 6.74.1, 6.74.0, 6.73.2, 6.73.1, 6.73.0, 6.72.0, 6.71.2, 6.71.1, 6.71.0, 6.70.0, 6.69.0, 6.68.0, 6.67.0, 6.66.0, 6.65.4, 6.65.3, 6.65.2, 6.65.1, 6.65.0, 6.64.3, 6.64.2, 6.64.1, 6.64.0, 6.63.0, 6.62.0, 6.61.4, 6.61.3, 6.61.1, 6.61.0, 6.60.0, 6.59.0, 6.58.0, 6.57.0, 6.56.0, 6.55.0, 6.54.4, 6.54.2, 6.54.1, 6.54.0, 6.53.4, 6.53.0, 6.51.0), the 5.2.0 through 5.33.0-rc.6 rows, and former `4.0.0` rows are rolled off to respect the P9 changelog cap; their full bodies remain in this file's git history, with release tags where present for historical versions. --- diff --git a/VERSION b/VERSION index 18bb4182d..a5f017a0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.5.0 +7.5.1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f0769d41e..036efd50a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,4 +1,4 @@ -# Ouroboros v7.5.0 — Architecture & Reference +# Ouroboros v7.5.1 — Architecture & Reference This is the present-tense operational map of Ouroboros (BIBLE P6), in three layers: structure (what exists and where), operation (files, env keys, state paths, endpoints, flows), and rationale; it is NOT a changelog, and version history lives in README.md, git tags, and the commit log. Every important WHY stays in this book at least briefly, while mechanism detail lives in the module docstring the map points to by name, and rationale must be self-contained — future maintainers should not need old commits to understand why a guard, review gate, or lifecycle exists. The chapters below are the book: each owns one section of the map, and a change replaces the description of the node it touched. diff --git a/docs/architecture/04-server-api-endpoints.md b/docs/architecture/04-server-api-endpoints.md index 2618a7317..22b583b52 100644 --- a/docs/architecture/04-server-api-endpoints.md +++ b/docs/architecture/04-server-api-endpoints.md @@ -85,7 +85,7 @@ Every `/api/files/*` operation resolves its requested path and refuses the opera | GET | `/api/tasks/{task_id}` | `gateway.tasks.api_task_get` | | GET | `/api/tasks/{task_id}/events` | `gateway.tasks.api_task_events` (legacy integer rank) | | POST | `/api/tasks/{task_id}/events` | `gateway.tasks.api_task_events` (read-only v2 cursor) | -| GET | `/api/tasks/{task_id}/artifacts/{name}` | `gateway.tasks.api_task_artifact` (the task's own stores via `task_archive`: a bare name is a top-level file, `?relpath=` a nested one, `?archive=` a directory ZIP; the detail's `artifact_archives` says what each ZIP holds. A row that records a digest is served only when its bytes still match it — a changed mutable file is 409 `artifact_identity_changed` naming the recorded digest, a failed capture 404 `artifact_unverified` — and the response says `x-ouroboros-artifact-identity: verified` or `unmeasured`; a ZIP member follows the same rule) | +| GET | `/api/tasks/{task_id}/artifacts/{name}` | `gateway.tasks.api_task_artifact` (the task's own stores via `task_archive`: a bare name is a top-level file, `?relpath=` a nested one, `?archive=` a directory ZIP; the detail's `artifact_archives` says what each ZIP holds. A row that records a digest is served only when its bytes still match it — a changed mutable file is 409 `artifact_identity_changed` naming the recorded digest, a failed capture 404 `artifact_unverified` — and the response says `x-ouroboros-artifact-identity: verified` or `unmeasured`; a ZIP member follows the same rule. Windows ordinary file/chat-media/ZIP downloads return HTTP 503 pending confined opens (issue #1297); bound `?source=` review downloads remain available) | | POST | `/api/tasks/{task_id}/cancel` | `gateway.tasks.api_task_cancel` | | POST | `/api/tasks/{task_id}/hurry` | `gateway.tasks.api_task_hurry` | | POST | `/api/tasks/{task_id}/resume` | `gateway.tasks.api_task_resume` | diff --git a/docs/install/index.html b/docs/install/index.html index 22c8543e0..3dea5b375 100644 --- a/docs/install/index.html +++ b/docs/install/index.html @@ -46,24 +46,24 @@ @@ -74,7 +74,7 @@

macOS quick start

    -
  1. Click Download for macOS (.dmg).
  2. +
  3. Click Download for macOS (.dmg).
  4. Open the DMG and drag Ouroboros.app onto the Applications shortcut.
  5. Open Ouroboros from Applications. If Gatekeeper asks, right-click the app and choose Open.
diff --git a/ouroboros/skill_loader.py b/ouroboros/skill_loader.py index 9f98fda90..46ba8e190 100644 --- a/ouroboros/skill_loader.py +++ b/ouroboros/skill_loader.py @@ -11,6 +11,7 @@ from __future__ import annotations import hashlib import logging import pathlib +import stat from dataclasses import asdict, dataclass, field from typing import Any, Callable, Dict, Iterable, List, Optional @@ -356,10 +357,11 @@ def _iter_payload_files( resolved = (skill_dir / rel).resolve() try: resolved.relative_to(resolved_root) - except ValueError: + # is_file() hides ELOOP; an unreadable declared entry cannot be omitted from its hash. + if stat.S_ISREG(resolved.stat().st_mode): + _add(resolved) + except (ValueError, FileNotFoundError, NotADirectoryError): return - if resolved.is_file(): - _add(resolved) # Broad walk: everything runtime-reachable, minus metadata/cache names. # Every candidate is resolved back under skill_dir so symlinks cannot leak diff --git a/ouroboros/tools/plan_evidence.py b/ouroboros/tools/plan_evidence.py index 225f56b6e..55422714f 100644 --- a/ouroboros/tools/plan_evidence.py +++ b/ouroboros/tools/plan_evidence.py @@ -14,6 +14,7 @@ from __future__ import annotations import codecs import ast +import errno from hashlib import sha256 import pathlib import json @@ -214,13 +215,15 @@ def _read_evidence( def _path_kind(path: pathlib.Path) -> str: - """``missing`` · ``directory`` · ``file`` (regular) · ``unreadable`` (stat failure or a + """``missing`` · ``directory`` · ``file`` · ``symlink_loop`` · ``unreadable`` (stat failure or a non-regular node: fifo/device/socket would block or never end a read).""" try: mode = path.stat().st_mode except FileNotFoundError: return "missing" - except (OSError, ValueError, RuntimeError): + except OSError as exc: + return "symlink_loop" if exc.errno == errno.ELOOP else "unreadable" + except (ValueError, RuntimeError): return "unreadable" if stat.S_ISDIR(mode): return "directory" diff --git a/pyproject.toml b/pyproject.toml index 9db49ce36..1a12ca4b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ouroboros" -version = "7.5.0" +version = "7.5.1" description = "Self-creating AI agent with constitution, background consciousness, and persistent identity" readme = "README.md" license = {text = "MIT"} diff --git a/site/install/index.html b/site/install/index.html index 0a95dd203..c8b0682f1 100644 --- a/site/install/index.html +++ b/site/install/index.html @@ -46,24 +46,24 @@ @@ -74,7 +74,7 @@

macOS quick start

    -
  1. Click Download for macOS (.dmg).
  2. +
  3. Click Download for macOS (.dmg).
  4. Open the DMG and drag Ouroboros.app onto the Applications shortcut.
  5. Open Ouroboros from Applications. If Gatekeeper asks, right-click the app and choose Open.
diff --git a/tests/conftest.py b/tests/conftest.py index 34648369d..429b76471 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -586,13 +586,16 @@ def _rebind_runtime_roots_between_tests(): @pytest.fixture(autouse=True) def _reset_custody_memo_between_tests(): - """The custody row memo is process-local and keyed by events-log path; a test - that rewrites its log in place (``write_text``) or reuses a path must never - inherit another test's consumed prefix (``delegate_custody_memo``).""" + """Isolate both custody caches: the row memo is keyed by events-log path, + while active custody is keyed only by run ID. Tests reuse both identities; + neither a consumed prefix nor a previous run's first-wins binding may leak.""" + from ouroboros import delegate_custody from ouroboros.delegate_custody_memo import reset_custody_memo + delegate_custody._CUSTODY.clear() reset_custody_memo() yield + delegate_custody._CUSTODY.clear() reset_custody_memo() diff --git a/tests/system_e2e/test_system_scenarios_w2.py b/tests/system_e2e/test_system_scenarios_w2.py index f279b7f25..ce3919c77 100644 --- a/tests/system_e2e/test_system_scenarios_w2.py +++ b/tests/system_e2e/test_system_scenarios_w2.py @@ -266,10 +266,9 @@ _CHILD_SHA_RE = re.compile(r"child_result_sha256[\"'=:\s]+([0-9a-f]{64})") def _s6_slot_binder(body: dict) -> str: - """Slot = wire model id × tool-bearing shape: the parent loop runs on - ``mock-model`` with tools, the supervisor's semantic-duplicate probe runs on the - light slot (same slug) WITHOUT tools, and the child runs on ``mock-child`` — so - every fixture ordinal is deterministic even while parent and child overlap.""" + """Bind parent ``mock-model`` and child ``mock-child`` by model and request shape. + Tool-less calls keep a separate slot, so an unexpected auxiliary model request + is a fixture miss even while parent and child overlap.""" return f"{body.get('model') or ''}|{'tools' if body.get('tools') else 'plain'}" @@ -301,8 +300,6 @@ S6_FIXTURE = { "objective": "List the repository root and report the entries you saw.", "expected_output": "A short list of repository root entries.", }}, - # The supervisor's admission duplicate-probe (light slot, tool-less). - ("root", "mock-model|plain", 1): {"final": "No existing task duplicates this request."}, ("root", "mock-model|tools", 2): _s6_wait_step, ("root", "mock-model|tools", 3): _s6_dispose_step, ("root", "mock-model|tools", 4): {"final": f"{S6_PARENT_MARKER}: child absorbed; done."}, diff --git a/tests/test_author_ui_kit_browser.py b/tests/test_author_ui_kit_browser.py index 642cb10f3..b99e10ad9 100644 --- a/tests/test_author_ui_kit_browser.py +++ b/tests/test_author_ui_kit_browser.py @@ -196,7 +196,7 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path assert route.evaluate("window.kitCspViolations") == [] assert module.evaluate("document.documentElement.dataset.theme") == "dark" page.evaluate("() => window.ouroTheme.set('light')") - module.wait_for_function("document.documentElement.dataset.theme === 'light'") + module.locator('html[data-theme="light"]').wait_for(state="attached") assert page.evaluate( "node => document.querySelector('[data-widget-key=\\\"module-old\\\"] iframe') === node", module_node, @@ -205,7 +205,7 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path "getComputedStyle(document.querySelector('.ui-control')).backgroundColor" ) == "rgb(245, 246, 248)" page.evaluate("() => window.ouroTheme.set('dark')") - module.wait_for_function("document.documentElement.dataset.theme === 'dark'") + module.locator('html[data-theme="dark"]').wait_for(state="attached") for frame in (module, route): frame.get_by_role("button", name="Preview", exact=True).wait_for() assert frame.get_by_label("Title", exact=True).input_value() == "My notes" @@ -322,7 +322,7 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path }""") page.wait_for_function("window.handlers.size === 1") page.evaluate("() => handlers.forEach(handler => handler({type:'ext:export_widget:tick', data:{value:'delivered'}}))") - module.wait_for_function("document.getElementById('root').dataset.event === 'delivered'") + module.locator('#root[data-event="delivered"]').wait_for(state="attached") with page.expect_download() as download: module.get_by_role("button", name="Export example", exact=True).click() assert Path(download.value.path()).read_text(encoding="utf-8") == "author kit export" @@ -335,7 +335,7 @@ def test_author_kit_authenticated_mount_and_lifetime(author_kit_server, tmp_path evidence = Path(os.environ.get("OUROBOROS_UI_EVIDENCE_OUT", str(tmp_path / "evidence"))) evidence.mkdir(parents=True, exist_ok=True) page.evaluate("() => window.ouroTheme.set('light')") - module.wait_for_function("document.documentElement.dataset.theme === 'light'") + module.locator('html[data-theme="light"]').wait_for(state="attached") module.locator('body').screenshot( path=str(evidence / f"author-kit-{browser_name}-module-light.png") ) diff --git a/tests/test_child_drive_settlement.py b/tests/test_child_drive_settlement.py index 9d537e740..30d7c40eb 100644 --- a/tests/test_child_drive_settlement.py +++ b/tests/test_child_drive_settlement.py @@ -117,7 +117,7 @@ def test_a_recorded_capture_whose_source_is_gone_stays_held_by_its_verified_cano @pytest.mark.parametrize("material", ["child_result", "registration", "file", "directory", "mailbox", "receipts"]) -def test_unreadable_material_retains_the_drive(tmp_path, material): +def test_unreadable_material_retains_the_drive(tmp_path, monkeypatch, material): data, drive, record = _cancelled_with_capture(tmp_path) store = artifacts.task_artifact_dir_path(drive, TASK) if material == "child_result": @@ -129,12 +129,26 @@ def test_unreadable_material_retains_the_drive(tmp_path, material): elif material == "file": extra = store / "notes.txt" extra.write_text("mutable note", encoding="utf-8") - extra.chmod(0) + original_open = Path.open + + def unreadable_open(path, *args, **kwargs): + if path == extra: + raise PermissionError("file read denied") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", unreadable_open) expected = "child_artifact_store_unreadable" elif material == "directory": (store / "tree").mkdir() (store / "tree" / "leaf.txt").write_text("leaf", encoding="utf-8") - (store / "tree").chmod(0) + original_scandir = os.scandir + + def unreadable_scandir(path): + if Path(path) == store / "tree": + raise PermissionError("directory read denied") + return original_scandir(path) + + monkeypatch.setattr(os, "scandir", unreadable_scandir) expected = "child_artifact_store_unreadable" elif material == "mailbox": mailbox = owner_mailbox._mailbox_path(drive, TASK) @@ -144,16 +158,22 @@ def test_unreadable_material_retains_the_drive(tmp_path, material): else: verification_receipts_path(drive, TASK, create=True).write_text("{torn\n", encoding="utf-8") expected = "verification_receipts_uncustodied" - try: - if os.name != "nt" and material in {"file", "directory"} and os.geteuid() == 0: - pytest.skip("root reads unreadable files") - outcome = _settle(data, drive) - finally: - for path in (store / "notes.txt", store / "tree"): - if path.exists(): - path.chmod(0o755) + # chmod(0) does not deny Windows reads and is bypassed by POSIX root. Inject + # the actual read failure so retention and recovery are tested on every host. + outcome = _settle(data, drive) assert outcome["status"] == "retained" and outcome["reason"] == expected, outcome assert drive.is_dir() and not _canonical(data, record["name"]).exists() + if material in {"file", "directory"}: + if material == "file": + monkeypatch.setattr(Path, "open", original_open) + else: + monkeypatch.setattr(os, "scandir", original_scandir) + assert _settle(data, drive)["status"] == "removed" + assert not drive.exists() + extra_name = "notes.txt" if material == "file" else "tree/leaf.txt" + assert _canonical(data, extra_name).read_text(encoding="utf-8") == ( + "mutable note" if material == "file" else "leaf" + ) def test_a_write_that_does_not_land_or_read_back_deletes_and_seals_nothing(tmp_path, monkeypatch): diff --git a/tests/test_cybergym_dispatch.py b/tests/test_cybergym_dispatch.py index b54c49a32..19e0fede9 100644 --- a/tests/test_cybergym_dispatch.py +++ b/tests/test_cybergym_dispatch.py @@ -443,11 +443,18 @@ def _transport_row(task_id: str) -> dict: } -def test_breaker_pauses_probes_and_resumes_instead_of_abandoning(tmp_path): +def test_breaker_pauses_probes_and_resumes_instead_of_abandoning(tmp_path, monkeypatch): """full1507: three transport rows from a ~100 s stall, not a dead isolate.""" + from concurrent.futures import ALL_COMPLETED, wait + from devtools.benchmarks.cybergym import cybergym_dispatch from devtools.benchmarks.cybergym.cybergym_dispatch import run_dispatched + # This scenario delivers a whole failed wave before any later success can + # reset the consecutive-failure streak. Worker scheduling is not its oracle. + monkeypatch.setattr(cybergym_dispatch, "wait", lambda futures, **kwargs: wait( + futures, **{**kwargs, "return_when": ALL_COMPLETED})) + clock = _PausingClock() probes: list[float] = [] # Gateway is "stalled" for the first two probes and answers on the third. @@ -457,17 +464,16 @@ def test_breaker_pauses_probes_and_resumes_instead_of_abandoning(tmp_path): probes.append(clock.now) return next(probe_answers) - outcomes = iter(["transport", "transport", "transport", "ok", "ok", "ok"]) + tasks = [_Task(f"arvo:{index}") for index in range(1, 7)] + failed_task_ids = {task.task_id for task in tasks[:3]} def run_one(task): - if next(outcomes) == "ok": + if task.task_id not in failed_task_ids: return {"task_id": task.task_id, "status": "completed"} return _transport_row(task.task_id) events: list[dict] = [] - tasks = [_Task(f"arvo:{index}") for index in range(1, 7)] for workers in (1, 3): - outcomes = iter(["transport", "transport", "transport", "ok", "ok", "ok"]) probe_answers = iter([False, False, True]) probes.clear() events.clear() @@ -486,6 +492,7 @@ def test_breaker_pauses_probes_and_resumes_instead_of_abandoning(tmp_path): clock=clock.monotonic, ) + assert [row["task_id"] for row in rows] == [task.task_id for task in tasks] assert [row["status"] for row in rows] == ["infra_failed"] * 3 + ["completed"] * 3 # Backoff schedule: first probe after 30 s, then +60 s, then +120 s. assert probes == [30.0, 90.0, 210.0] diff --git a/tests/test_evolution_commit_receipt.py b/tests/test_evolution_commit_receipt.py index 0ea50aa3e..b412eb3b5 100644 --- a/tests/test_evolution_commit_receipt.py +++ b/tests/test_evolution_commit_receipt.py @@ -226,6 +226,7 @@ def test_rescue_link_uses_shared_campaign_cas_and_preserves_commit_receipt( def test_commit_receipt_uses_campaign_sidecar_before_rescue(tmp_path, monkeypatch): + from ouroboros import platform_layer from ouroboros.platform_layer import ( acquire_exclusive_file_lock, release_exclusive_file_lock, @@ -239,8 +240,20 @@ def test_commit_receipt_uses_campaign_sidecar_before_rescue(tmp_path, monkeypatc lock_fd = acquire_exclusive_file_lock(lock_path, timeout_sec=1.0) assert lock_fd is not None done = threading.Event() + acquiring = threading.Event() + released = threading.Event() result = {} + def _acquire_after_release(path, **kwargs): + if path == lock_path: + acquiring.set() + released.wait() + return acquire_exclusive_file_lock(path, **kwargs) + + # Keep real contention, but start the acquisition timeout after the fixture + # releases its lock, as in the rescue interleaving test above. + monkeypatch.setattr(platform_layer, "acquire_exclusive_file_lock", _acquire_after_release) + def _record() -> None: result.update(evolution_lifecycle.record_evolution_commit( campaign["id"], tx["transaction_id"], tx["task_id"], "4" * 40, @@ -250,11 +263,15 @@ def test_commit_receipt_uses_campaign_sidecar_before_rescue(tmp_path, monkeypatc thread = threading.Thread(target=_record, daemon=True) thread.start() try: + assert acquire_exclusive_file_lock(lock_path, timeout_sec=0.001) is None + assert acquiring.wait(2.0) is True assert done.wait(0.1) is False finally: release_exclusive_file_lock(lock_path, lock_fd) + released.set() + thread.join(timeout=2.0) assert done.wait(2.0) is True - thread.join(timeout=1.0) + assert not thread.is_alive() assert result["ok"] is True assert evolution_lifecycle._read_evolution_campaign()["active_transaction"][ "commit_receipt" diff --git a/tests/test_headless_task_artifacts.py b/tests/test_headless_task_artifacts.py index 64906ab5f..965f90cf4 100644 --- a/tests/test_headless_task_artifacts.py +++ b/tests/test_headless_task_artifacts.py @@ -17,6 +17,7 @@ from starlette.applications import Starlette from starlette.routing import Route from starlette.testclient import TestClient +from ouroboros.gateway import task_archive from ouroboros.gateway.tasks import ( api_task_artifact, ) @@ -39,6 +40,16 @@ from tests._headless_cli_shared import ( # noqa: F401 (autouse fixture applies ) +def _assert_file_response(response, content: bytes) -> None: + # Owner-approved #1297: platforms without confined opens return a typed 503. + if task_archive.CONFINED: + assert response.status_code == 200 + assert response.content == content + else: + assert response.status_code == 503 + assert response.json()["reason_code"] == "artifact_unavailable" + + def test_copy_child_result_cannot_overwrite_finalized_accounting(tmp_path): """F2: once the root's terminal checkpoint has finalized accounting (task_cost_finalized rides the same write as post_task_synthesis), a late @@ -386,7 +397,7 @@ def test_task_artifact_endpoint_serves_only_declared_artifacts(tmp_path): app.state.drive_root = data client = TestClient(app) - assert client.get("/api/tasks/task-artifact/artifacts/workspace.patch").text.startswith("diff --git") + _assert_file_response(client.get("/api/tasks/task-artifact/artifacts/workspace.patch"), b"diff --git a/a b/a\n") assert client.get("/api/tasks/task-artifact/artifacts/missing.patch").status_code == 404 assert client.get("/api/tasks/task-artifact/artifacts/bad%5Cname").status_code == 400 @@ -415,8 +426,7 @@ def test_task_artifact_endpoint_serves_manifest_artifact_after_status_repair(tmp response = TestClient(app).get("/api/tasks/orphaned/artifacts/report.html") - assert response.status_code == 200 - assert response.text == "

ok

" + _assert_file_response(response, b"

ok

") def test_task_artifact_endpoint_serves_child_drive_artifact_read_only_after_status_repair(tmp_path): @@ -457,9 +467,8 @@ def test_task_artifact_endpoint_serves_child_drive_artifact_read_only_after_stat response = TestClient(app).get("/api/tasks/childart/artifacts/report.html") - # Two-root read: the task's own child store serves it; nothing is copied or created. - assert response.status_code == 200 - assert response.text == "

child

" + # The own child store is read when supported; neither outcome copies or creates it. + _assert_file_response(response, b"

child

") assert not task_artifacts_dir(data, "childart", create=False).exists() @@ -664,8 +673,7 @@ def test_task_artifact_endpoint_serves_exact_chat_media_without_task_result(tmp_ client = TestClient(app) response = client.get(f"/api/tasks/ephemeral1/artifacts/{stored['name']}") - assert response.status_code == 200 - assert response.content == b"photo-bytes" + _assert_file_response(response, b"photo-bytes") assert collect_task_artifact_records(data, "ephemeral1") == [] assert client.get("/api/tasks/ephemeral1/artifacts/chat-media-bad.png").status_code == 404 diff --git a/tests/test_large_task_artifacts.py b/tests/test_large_task_artifacts.py index 3faac8bf9..57503c868 100644 --- a/tests/test_large_task_artifacts.py +++ b/tests/test_large_task_artifacts.py @@ -629,7 +629,7 @@ def test_file_verification_does_not_run_on_the_asgi_loop(tmp_path, monkeypatch, import uvicorn from starlette.applications import Starlette from starlette.routing import Route - from ouroboros.gateway import tasks + from ouroboros.gateway import task_archive, tasks from ouroboros.task_results import write_task_result api_task_artifact = tasks.api_task_artifact @@ -667,14 +667,24 @@ def test_file_verification_does_not_run_on_the_asgi_loop(tmp_path, monkeypatch, async def request(): async with httpx.AsyncClient(timeout=10) as client: reply = await client.request(method, f'http://127.0.0.1:{sock.getsockname()[1]}/api/tasks/download/artifacts/{record["name"]}') - assert reply.status_code == 200, reply.text - assert reply.content == (source.read_bytes() if method == 'GET' else b'') + if task_archive.CONFINED: + assert reply.status_code == 200, reply.text + assert reply.content == (source.read_bytes() if method == 'GET' else b'') + else: + assert reply.status_code == 503, reply.text + if method == 'GET': + assert reply.json()['reason_code'] == 'artifact_unavailable' + else: + assert reply.content == b'' asyncio.run(request()) assert bool(materialization_calls) is (not registered) assert all(identity != thread.ident for identity in materialization_calls) - if registered: - assert len(calls) == 1, "registered downloads verify once per request" - assert calls and all(identity != thread.ident for identity in calls), 'whole-file hashing ran synchronously on the ASGI event loop' + if task_archive.CONFINED: + if registered: + assert len(calls) == 1, "registered downloads verify once per request" + assert calls and all(identity != thread.ident for identity in calls), 'whole-file hashing ran synchronously on the ASGI event loop' + else: + assert calls == [], "unsupported platforms refuse before reading file bytes (#1297)" finally: server.should_exit = True thread.join(10) diff --git a/tests/test_net_transport_extra_ca.py b/tests/test_net_transport_extra_ca.py index 6f4451933..cdb401ffb 100644 --- a/tests/test_net_transport_extra_ca.py +++ b/tests/test_net_transport_extra_ca.py @@ -45,11 +45,18 @@ def _throwaway_ca(tmp_path: pathlib.Path): now = _dt.datetime.now(_dt.timezone.utc) ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Ouroboros throwaway test CA")]) + # Python 3.13 enables strict X.509 verification: CA SKI/key usage and leaf AKI are required. ca = ( x509.CertificateBuilder().subject_name(ca_name).issuer_name(ca_name) .public_key(ca_key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - _dt.timedelta(days=1)).not_valid_after(now + _dt.timedelta(days=2)) .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) + .add_extension(x509.KeyUsage( + digital_signature=False, content_commitment=False, key_encipherment=False, + data_encipherment=False, key_agreement=False, key_cert_sign=True, crl_sign=True, + encipher_only=False, decipher_only=False, + ), critical=True) .sign(ca_key, hashes.SHA256()) ) leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) @@ -59,6 +66,7 @@ def _throwaway_ca(tmp_path: pathlib.Path): .issuer_name(ca_name).public_key(leaf_key.public_key()).serial_number(x509.random_serial_number()) .not_valid_before(now - _dt.timedelta(days=1)).not_valid_after(now + _dt.timedelta(days=2)) .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) .sign(ca_key, hashes.SHA256()) ) tmp_path.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_review_source_handles.py b/tests/test_review_source_handles.py index 966d31a7e..14f37869a 100644 --- a/tests/test_review_source_handles.py +++ b/tests/test_review_source_handles.py @@ -9,6 +9,7 @@ from starlette.routing import Route from starlette.testclient import TestClient from ouroboros import artifacts, review_projection +from ouroboros.gateway import task_archive from ouroboros.gateway.tasks import api_task_artifact from ouroboros.headless import copy_child_task_result, prepare_task_drive, remove_subagent_task_drive from ouroboros.task_results import write_task_result @@ -82,7 +83,12 @@ def test_source_download_is_bound_and_distinct_from_same_named_user_file(tmp_pat app.state.drive_root = tmp_path with TestClient(app) as client: url = f"/api/tasks/applied/artifacts/{name}" - assert client.get(url).content == b"user result" + artifact = client.get(url) + if task_archive.CONFINED: + assert artifact.status_code == 200 and artifact.content == b"user result" + else: + assert artifact.status_code == 503 + assert artifact.json()["reason_code"] == "artifact_unavailable" source = client.get(url, params={"source": ref["path"]}) assert source.status_code == 200 assert hashlib.sha256(source.content).hexdigest() == ref["sha256"] diff --git a/tests/test_test_environment.py b/tests/test_test_environment.py index f8d84054b..6a83b46df 100644 --- a/tests/test_test_environment.py +++ b/tests/test_test_environment.py @@ -294,7 +294,7 @@ _SESSION_PROBE = """ import json, os, pathlib, tempfile def pytest_sessionstart(session): - worker = os.environ.get("PYTEST_XDIST_WORKER", "controller") + worker = getattr(session.config, "workerinput", {}).get("workerid", "controller") record = {"tmpdir": os.environ.get("TMPDIR", ""), "gettempdir": tempfile.gettempdir(), "session_root": str(pathlib.Path(os.environ["OUROBOROS_DATA_DIR"]).parent)} pathlib.Path(os.environ["SESSION_PROBE_OUT"], worker + ".json").write_text(json.dumps(record)) diff --git a/uv.lock b/uv.lock index 2716030fa..bacc68cfe 100644 --- a/uv.lock +++ b/uv.lock @@ -1535,7 +1535,7 @@ wheels = [ [[package]] name = "ouroboros" -version = "7.5.0" +version = "7.5.1" source = { editable = "." } dependencies = [ { name = "croniter" }, diff --git a/web/modules/api_types.js b/web/modules/api_types.js index 6ac31dcf0..423dd5bf5 100644 --- a/web/modules/api_types.js +++ b/web/modules/api_types.js @@ -1525,7 +1525,7 @@ export const MAX_QUIZ_OPTIONS = 6; // REFUSES a longer comment (it is delivered verbatim, never truncated), so // the card must not offer to send one. export const MAX_DECISION_COMMENT = 2000; -export const GATEWAY_CONTRACT_VERSION = '7.5.0'; +export const GATEWAY_CONTRACT_VERSION = '7.5.1'; /** * @typedef {Object} ChatHistoryPosition diff --git a/web/package-lock.json b/web/package-lock.json index 07d8cf0e5..61ee60679 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "ouroboros-web", - "version": "7.5.0", + "version": "7.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ouroboros-web", - "version": "7.5.0", + "version": "7.5.1", "devDependencies": { "eslint": "10.10.0", "globals": "17.12.0" diff --git a/web/package.json b/web/package.json index af5582dac..c32e637b3 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "ouroboros-web", - "version": "7.5.0", + "version": "7.5.1", "private": true, "type": "module", "description": "Ouroboros browser UI package boundary",