From 071c87e6f43067b02b7f452d60feca17f57b9444 Mon Sep 17 00:00:00 2001 From: Resolver-TNG <45145357+Resolver-TNG@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:08:28 +0900 Subject: [PATCH 1/5] CI trial 3: authority identity with size and change time (#661), small group fixes and the memory-store path cost (#662) --- CHANGELOG.md | 6 + docs/authority-service.md | 10 +- docs/authority-service_ja.md | 10 +- src/stiracore/authority/epoch_store.py | 62 +++++++- src/stiracore/authority/latches.py | 2 +- src/stiracore/authority/lifecycle_events.py | 18 ++- src/stiracore/authority/outbox.py | 2 +- src/stiracore/authority/private_file.py | 36 +++-- src/stiracore/authority/service.py | 2 +- src/stiracore/authority/watchdog.py | 2 +- src/stiracore/grants/store.py | 2 +- src/stiracore/tools/memory_store.py | 18 ++- src/stiracore/tools/pdf_extract.py | 4 +- tests/test_authority_inode_reuse.py | 155 ++++++++++++++++++++ tests/test_authority_lifecycle.py | 7 +- tests/test_file_tool_observations.py | 21 ++- tests/test_grants_board_read.py | 34 ++++- tests/test_grants_executor_server.py | 8 +- tests/test_ja_hybrid_classifier.py | 14 +- tests/test_memory_symlink_ancestors.py | 51 +++++++ tests/test_server_startup_smoke.py | 20 ++- 21 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 tests/test_authority_inode_reuse.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 606116c..353adb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -265,6 +265,12 @@ Overview, how to try it and the known limits: docs/autonomy.md. - **Dependencies** — `strands-agents-tools` is held below 0.8.10 (`>=0.8.0,<0.8.10`): the exec bridge refuses a release it was not audited against (0.8.9), so a fresh install got 0.8.10 and no exec-class tools. Deliberate until a newer release is audited (#296). +- **Authority and grant stores** — a delete + recreate of the epoch store, latch store, + lifecycle log, outbox, watchdog record or grant store is now detected on Linux too: the + file identity is device, inode, size and `st_ctime_ns` (recorded by the writer after its + own write and rename), not device and inode alone, which ext4 and tmpfs reuse at once + (#661). An outside append to the lifecycle log is now caught at the next append + (`lifecycle_log_replaced`), not only at the watchdog's next pass. - **Schedules** — Resume on `/schedules` with the default browser confirmation channel opens the confirmation (checkbox) dialog after the resume dialog instead of answering "Cancelled; nothing diff --git a/docs/authority-service.md b/docs/authority-service.md index 4fd8099..14e2614 100644 --- a/docs/authority-service.md +++ b/docs/authority-service.md @@ -39,8 +39,14 @@ starts is therefore always `(incarnation, epoch)`, never the epoch alone. The file is read without following a symlink and without blocking (a FIFO), and the opened descriptor is checked again (regular file, this user, mode `0600`, one link, the -same device and inode as the path). Before every replace the current file is read again -and must be exactly the one this service wrote last; otherwise nothing is written. +same device, inode, size and change time as the path). Before every replace the current +file is read again and must be exactly the one this service wrote last — the same bytes +and the same device, inode, size and `st_ctime_ns`, recorded after its own write and +rename — otherwise nothing is written. The change time is part of the identity because +Linux (ext4, tmpfs) gives a freed inode number to the next new file at once: a delete and +recreate with the same bytes keeps the inode number but not the change time (#661). The +same identity is used by the latch store, the lifecycle log, the outbox, the watchdog's +record and the grant store. | Reason code | Meaning | |---|---| diff --git a/docs/authority-service_ja.md b/docs/authority-service_ja.md index 878400e..57ccf69 100644 --- a/docs/authority-service_ja.md +++ b/docs/authority-service_ja.md @@ -37,9 +37,13 @@ rename → ディレクトリの flush で書く。最新 16 件の記録を持 `(インカーネーション, エポック)` で、エポックだけでは決めない。 ファイルはシンボリックリンクをたどらず、ブロックせずに(FIFO でも)開き、開いた記述子を -もう一度確かめる(通常ファイル、この利用者、モード `0600`、リンク 1 本、パスと同じデバイスと -inode)。置き換える前には毎回いまのファイルを読み直し、このサービスが最後に書いたものと -完全に同じでなければ何も書かない。 +もう一度確かめる(通常ファイル、この利用者、モード `0600`、リンク 1 本、パスと同じデバイス・ +inode・サイズ・変更時刻)。置き換える前には毎回いまのファイルを読み直し、このサービスが最後に +書いたものと完全に同じ(同じバイト、そして自分の書込みと rename の後に記録したデバイス・inode・ +サイズ・`st_ctime_ns`)でなければ何も書かない。変更時刻を同一性に含めるのは、Linux(ext4、 +tmpfs)が解放された inode 番号をすぐ次の新しいファイルに渡すため: 同じバイトでの削除と作り直しは +inode 番号を保っても変更時刻は保たない(#661)。ラッチストア、ライフサイクルログ、outbox、 +ウォッチドッグの記録、グラントストアも同じ同一性を使う。 | 理由コード | 意味 | |---|---| diff --git a/src/stiracore/authority/epoch_store.py b/src/stiracore/authority/epoch_store.py index 32ad3d8..7bea0ea 100644 --- a/src/stiracore/authority/epoch_store.py +++ b/src/stiracore/authority/epoch_store.py @@ -56,7 +56,10 @@ "EPOCH_FILE", "KEEP_RECORDS", "EpochRecord", + "Identity", "LoadResult", + "identity", + "install", "load", "next_record", "write", @@ -116,7 +119,7 @@ class LoadResult: status: str # missing | ok | bad records: tuple[EpochRecord, ...] = () reason: str | None = None - identity: tuple[int, int] | None = None # (st_dev, st_ino) of the file that was read + identity: Identity | None = None # :func:`identity` of the file that was read @property def last(self) -> EpochRecord | None: @@ -212,6 +215,46 @@ def _parse(raw: bytes) -> LoadResult: return LoadResult("ok", tuple(records)) +Identity = tuple[int, int, int, int] + + +def identity(info: os.stat_result) -> Identity: + """``(st_dev, st_ino, st_size, st_ctime_ns)``: the file a writer left behind (#661). + The device / inode pair alone is not enough on Linux: ext4 and tmpfs hand a freed inode + number to the next new file at once, so a delete + recreate (any bytes) between two + passes would keep it. Every write, append, rename or chmod sets ``st_ctime``; a writer + records this value after its own last change (write, flush, rename), so only a change + made by someone else differs. (``grants/progress.py`` uses the same idea.)""" + return (info.st_dev, info.st_ino, info.st_size, info.st_ctime_ns) + + +def install(fd: int, tmp: Path, path: Path) -> Identity: + """Rename the written temporary file ``tmp`` (open as ``fd``, closed here) over + ``path``; the :func:`identity` of the installed file, taken *after* the rename (a + rename sets ``st_ctime`` on Linux). On POSIX the descriptor stays open across the + rename, so the inode cannot be freed and reused before it is identified; Windows + cannot rename an open file, so there the path is checked to still be the written + file (same device / file index) right after the rename.""" + if os.name == "posix": + try: + os.replace(tmp, path) + return identity(os.fstat(fd)) + finally: + os.close(fd) + try: + written = os.fstat(fd) + finally: + os.close(fd) + os.replace(tmp, path) + after = os.lstat(path) + if not stat.S_ISREG(after.st_mode) or (after.st_dev, after.st_ino) != ( + written.st_dev, + written.st_ino, + ): + raise OSError(errno.EEXIST, "replaced right after the rename", str(path)) + return identity(after) + + def _unsafe(info: os.stat_result) -> bool: """Not a regular, singly linked file of this user with mode ``0600`` or tighter.""" if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: @@ -249,6 +292,9 @@ def load(data_dir: Path) -> LoadResult: opened = os.fstat(fd) except OSError: return LoadResult("bad", reason="epoch_store_unreadable") + # The path and the descriptor are the same file (device / inode): a size or + # change-time difference here is a concurrent append, not a swap; the full + # identity returned below is compared with the writer's own by the caller. if _unsafe(opened) or (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): return LoadResult("bad", reason="epoch_store_unsafe") if opened.st_size > _MAX_BYTES: @@ -262,7 +308,7 @@ def load(data_dir: Path) -> LoadResult: result = _parse(raw) if result.status != "ok": return result - return LoadResult("ok", result.records, identity=(opened.st_dev, opened.st_ino)) + return LoadResult("ok", result.records, identity=identity(opened)) def _flush(fd: int) -> None: @@ -278,9 +324,9 @@ def _flush(fd: int) -> None: os.fsync(fd) -def write(data_dir: Path, records: tuple[EpochRecord, ...]) -> tuple[int, int]: +def write(data_dir: Path, records: tuple[EpochRecord, ...]) -> Identity: """Replace the file with ``records`` (the newest :data:`KEEP_RECORDS`), durably; the - new file's ``(st_dev, st_ino)``. The caller checks the current file first + new file's :func:`identity`. The caller checks the current file first (:func:`load`): this function replaces whatever is there.""" directory = Path(data_dir) path = directory / EPOCH_FILE @@ -297,13 +343,13 @@ def write(data_dir: Path, records: tuple[EpochRecord, ...]) -> tuple[int, int]: while view: view = view[os.write(fd, view) :] _flush(fd) - written = os.fstat(fd) - finally: + except BaseException: os.close(fd) - os.replace(tmp, path) + raise + written = install(fd, tmp, path) except BaseException: with contextlib.suppress(OSError): os.unlink(tmp) raise fsync_dir(directory, _flush) - return (written.st_dev, written.st_ino) + return written diff --git a/src/stiracore/authority/latches.py b/src/stiracore/authority/latches.py index 8ccb277..be8910d 100644 --- a/src/stiracore/authority/latches.py +++ b/src/stiracore/authority/latches.py @@ -217,7 +217,7 @@ def __init__(self, data_dir: Path | None) -> None: self._dir = None if data_dir is None else Path(data_dir) self._lock = threading.Lock() self._records: list[dict] = [] - self._identity: tuple[int, int] | None = None + self._identity: tuple[int, ...] | None = None self._bad: str | None = None if self._dir is None: return diff --git a/src/stiracore/authority/lifecycle_events.py b/src/stiracore/authority/lifecycle_events.py index 6e3cd3b..bb853c2 100644 --- a/src/stiracore/authority/lifecycle_events.py +++ b/src/stiracore/authority/lifecycle_events.py @@ -34,11 +34,12 @@ import math import os import re +import stat import threading from pathlib import Path from typing import Any -from . import private_file +from . import epoch_store, private_file from .latches import LATCHES __all__ = [ @@ -494,8 +495,8 @@ def __init__(self, data_dir: Path | None, *, rotate: bool = True) -> None: self._dir = None if data_dir is None else Path(data_dir) self._lock = threading.Lock() self._bad: str | None = None - self._identity: tuple[int, int] | None = None - self._old_identity: tuple[int, int] | None = None + self._identity: tuple[int, ...] | None = None + self._old_identity: tuple[int, ...] | None = None self._size = 0 self._last_hash = _ZERO self._last_seq = 0 @@ -575,7 +576,16 @@ def _rotate_locked(self) -> None: from stiracore.durable_dir import fsync_dir fsync_dir(self._dir) - self._old_identity = self._identity + # A rename sets st_ctime (Linux), so the sealed segment's identity is taken again; + # it must still be the file this log appended to last (#661). + moved = os.lstat(self._dir / OLD_LOG_FILE) + if ( + self._identity is None + or not stat.S_ISREG(moved.st_mode) + or (moved.st_dev, moved.st_ino, moved.st_size) != self._identity[:3] + ): + raise private_file.Replaced("replaced") + self._old_identity = epoch_store.identity(moved) self._old_digest = self._cur_hash.hexdigest() self._old_size = self._size self._identity = None diff --git a/src/stiracore/authority/outbox.py b/src/stiracore/authority/outbox.py index aa5b55a..fae296a 100644 --- a/src/stiracore/authority/outbox.py +++ b/src/stiracore/authority/outbox.py @@ -245,7 +245,7 @@ def __init__( self._items: list[dict] = [] self._seen: set[str] = set() self._acked: set[str] = set() - self._outbox_identity: tuple[int, int] | None = None + self._outbox_identity: tuple[int, ...] | None = None self._outbox_seen: bytes | None = None if self.log.unhealthy_reason is not None: self._bad = self.log.unhealthy_reason diff --git a/src/stiracore/authority/private_file.py b/src/stiracore/authority/private_file.py index a890598..79301b5 100644 --- a/src/stiracore/authority/private_file.py +++ b/src/stiracore/authority/private_file.py @@ -14,6 +14,10 @@ O_NONBLOCK`` (created ``0600`` with ``O_EXCL`` when missing), the descriptor checked as above and against the identity the caller saw last, one line written and flushed. +The identity is :func:`.epoch_store.identity` — device, inode, size and ``st_ctime_ns`` +(#661: Linux reuses a freed inode number at once) — recorded by the writer after its own +last change, so a delete + recreate by someone else differs even with the same bytes. + Reason codes are ``_unsafe`` / ``_unreadable`` / ``_malformed`` (the caller's parser adds its own). Nothing here repairs a file. Same limits as the epoch store: an unkeyed hash chain detects corruption, not tampering by the same user; the checks narrow @@ -31,7 +35,7 @@ from stiracore.durable_dir import fsync_dir -from .epoch_store import _flush, _unsafe +from .epoch_store import Identity, _flush, _unsafe, identity, install __all__ = ["ReadResult", "Replaced", "append_line", "read", "replace"] @@ -41,7 +45,7 @@ class ReadResult: status: str # missing | ok | bad raw: bytes = b"" reason: str | None = None - identity: tuple[int, int] | None = None + identity: Identity | None = None def read(path: Path, *, max_bytes: int, prefix: str) -> ReadResult: @@ -68,6 +72,9 @@ def read(path: Path, *, max_bytes: int, prefix: str) -> ReadResult: opened = os.fstat(fd) except OSError: return ReadResult("bad", reason=f"{prefix}_unreadable") + # The path and the descriptor are the same file (device / inode): a size or + # change-time difference here is a concurrent append, not a swap; the full + # identity returned below is compared with the writer's own by the caller. if _unsafe(opened) or (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): return ReadResult("bad", reason=f"{prefix}_unsafe") if opened.st_size > max_bytes: @@ -88,12 +95,12 @@ def read(path: Path, *, max_bytes: int, prefix: str) -> ReadResult: raw = b"".join(chunks) if len(raw) > max_bytes: return ReadResult("bad", reason=f"{prefix}_malformed") - return ReadResult("ok", raw, identity=(opened.st_dev, opened.st_ino)) + return ReadResult("ok", raw, identity=identity(opened)) -def replace(directory: Path, name: str, body: bytes) -> tuple[int, int]: - """Replace ``directory / name`` with ``body``, durably; the new ``(st_dev, st_ino)``. - The caller checks the current file first (:func:`read`).""" +def replace(directory: Path, name: str, body: bytes) -> Identity: + """Replace ``directory / name`` with ``body``, durably; the new file's identity (taken + after the rename). The caller checks the current file first (:func:`read`).""" directory = Path(directory) tmp = directory / f".{name}.{os.getpid()}.{os.urandom(4).hex()}.tmp" flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) @@ -104,27 +111,27 @@ def replace(directory: Path, name: str, body: bytes) -> tuple[int, int]: while view: view = view[os.write(fd, view) :] _flush(fd) - written = os.fstat(fd) - finally: + except BaseException: os.close(fd) - os.replace(tmp, directory / name) + raise + written = install(fd, tmp, directory / name) except BaseException: with contextlib.suppress(OSError): os.unlink(tmp) raise fsync_dir(directory, _flush) - return (written.st_dev, written.st_ino) + return written class Replaced(OSError): """The file is not the one the caller saw last (or is unsafe): nothing was written.""" -def append_line(path: Path, line: bytes, expected: tuple[int, int] | None) -> tuple[int, int]: +def append_line(path: Path, line: bytes, expected: Identity | None) -> Identity: """Append one line (``line`` ends with ``\\n``) and flush it. ``expected`` is the identity the caller read or wrote last (``None``: the file must not exist yet and is created ``0600``). A different, unsafe or vanished file raises :class:`Replaced`; - any I/O failure raises ``OSError``. Returns the identity written to.""" + any I/O failure raises ``OSError``. Returns the file's identity after the append.""" path = Path(path) base = os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) created = expected is None @@ -142,14 +149,15 @@ def append_line(path: Path, line: bytes, expected: tuple[int, int] | None) -> tu info = os.fstat(fd) if _unsafe(info): raise Replaced("unsafe") - if not created and (info.st_dev, info.st_ino) != expected: + if not created and identity(info) != expected: raise Replaced("replaced") view = memoryview(line) while view: view = view[os.write(fd, view) :] _flush(fd) + written = identity(os.fstat(fd)) finally: os.close(fd) if created: fsync_dir(path.parent, _flush) - return (info.st_dev, info.st_ino) + return written diff --git a/src/stiracore/authority/service.py b/src/stiracore/authority/service.py index 571552d..c0e7f00 100644 --- a/src/stiracore/authority/service.py +++ b/src/stiracore/authority/service.py @@ -105,7 +105,7 @@ def __init__(self, data_dir: Path | None) -> None: self._data_dir = None if data_dir is None else Path(data_dir) self._io_lock = threading.Lock() self._records: tuple[epoch_store.EpochRecord, ...] = () - self._identity: tuple[int, int] | None = None # the file this service wrote last + self._identity: tuple[int, ...] | None = None # the file this service wrote last self._persisted = 0 self._reasons: deque[tuple[int, str]] = deque(maxlen=_REASONS_KEPT) # Settlement (all under the leaf lock). Every narrowing queues one batch diff --git a/src/stiracore/authority/watchdog.py b/src/stiracore/authority/watchdog.py index 56b5422..88f700e 100644 --- a/src/stiracore/authority/watchdog.py +++ b/src/stiracore/authority/watchdog.py @@ -148,7 +148,7 @@ def __init__( self._passes = 0 self._failures = 0 self._heartbeat_stale = False - self._record_identity: tuple[int, int] | None = None + self._record_identity: tuple[int, ...] | None = None self._record_seen: bytes | None = None self._record_bad: str | None = None self._pass_lock = threading.Lock() # one pass at a time (thread, tests, shutdown) diff --git a/src/stiracore/grants/store.py b/src/stiracore/grants/store.py index fb0b184..6609cab 100644 --- a/src/stiracore/grants/store.py +++ b/src/stiracore/grants/store.py @@ -132,7 +132,7 @@ def __init__(self, data_dir: Path | None) -> None: self._lock = threading.Lock() # I/O lock: never taken under the service's lock self._bad: str | None = None self._records: list[dict] = [] - self._identity: tuple[int, int] | None = None + self._identity: tuple[int, ...] | None = None self._raw: bytes | None = None self._loaded = False diff --git a/src/stiracore/tools/memory_store.py b/src/stiracore/tools/memory_store.py index 97c3e38..9024d25 100644 --- a/src/stiracore/tools/memory_store.py +++ b/src/stiracore/tools/memory_store.py @@ -70,6 +70,12 @@ def __init__(self, base_dir: Path, agent_id: str, read_only_paths: set[str] | No self.memory_dir = self.base_dir / "agents" / agent_id / "memory" self.read_only_paths: set[str] = set(DEFAULT_READ_ONLY_PATHS) | set(read_only_paths or ()) self._reject_links(self.memory_dir) + # Resolved once (#662): ``base_dir`` is already resolved and the walk above proved + # that no component from it down to the memory directory is a link, so this is the + # memory directory itself. Every later containment check compares against this + # value instead of resolving the root again on each read (Windows: each resolve is + # a syscall chain per component, and the recall tests do 100k+ of them). + self._memory_root = self.memory_dir.resolve() for layer in MemoryLayer: directory = self.memory_dir / layer.value self._reject_links(directory) @@ -151,7 +157,10 @@ def _path(self, layer: MemoryLayer, key: str) -> Path: directory = self.memory_dir / layer.value path = directory.joinpath(*parts[:-1], f"{parts[-1]}.md") self._reject_links(path) - if not path.resolve().is_relative_to(self.memory_dir.resolve()): + # The key has no ``..`` or separator (:meth:`_validate_key`) and the walk above + # proved that no component from the resolved ``base_dir`` down to the entry is a + # link, so ``path.resolve()`` is ``path``: the containment check is lexical (#662). + if not path.is_relative_to(self._memory_root): raise ValueError("Memory entries must remain inside the memory directory.") return path @@ -363,7 +372,7 @@ def write_text( self._reject_links(path.parent) path.parent.mkdir(parents=True, exist_ok=True) self._reject_links(path.parent) - if not path.parent.resolve().is_relative_to(self.memory_dir.resolve()): + if not path.parent.is_relative_to(self._memory_root): # link-free: see :meth:`_path` raise ValueError("Memory entries must remain inside the memory directory.") kept_times: tuple[int, int] | None = None @@ -457,7 +466,10 @@ def _entry_keys(self, layer: MemoryLayer) -> Iterator[str]: for path in sorted(directory.rglob("*.md")): if path.is_symlink() or not path.is_file(): continue - self._reject_links(path) + # No walk per entry (#662: six lstat calls each, 1.1M in the recall tests): + # ``rglob`` does not descend into a symlinked directory, so no component below + # the layer directory can be a link, the layer directory and its ancestors were + # checked just above, and every read of the key walks the whole path again. yield path.relative_to(directory).with_suffix("").as_posix() def count_entries(self, layer: MemoryLayer, *, exclude_prefix: str | None = None) -> int: diff --git a/src/stiracore/tools/pdf_extract.py b/src/stiracore/tools/pdf_extract.py index 4bf6078..8713aa2 100644 --- a/src/stiracore/tools/pdf_extract.py +++ b/src/stiracore/tools/pdf_extract.py @@ -62,7 +62,9 @@ def out(**data): try: import resource resource.setrlimit(resource.RLIMIT_CPU, (cpu, cpu)) - if sys.platform != "darwin": # macOS accepts RLIMIT_AS but does not enforce it + # macOS accepts RLIMIT_AS but does not enforce it. A limit <= 0 is no limit (as in the + # parent): Linux takes a negative value as a huge unsigned one, i.e. unlimited (#662). + if sys.platform != "darwin" and mem > 0: resource.setrlimit(resource.RLIMIT_AS, (mem, mem)) limited = True except (ImportError, ValueError, OSError): diff --git a/tests/test_authority_inode_reuse.py b/tests/test_authority_inode_reuse.py new file mode 100644 index 0000000..93ccc2e --- /dev/null +++ b/tests/test_authority_inode_reuse.py @@ -0,0 +1,155 @@ +"""#661: a delete + recreate of an authority / grant file is detected even when the +filesystem hands the freed inode number to the new file (ext4 and tmpfs do so at once). + +The identity of these files is ``(st_dev, st_ino, st_size, st_ctime_ns)``, recorded by the +writer after its own last change. macOS (APFS) does not reuse an inode number at once, so +the reuse is made deterministic here: after the recreate, ``os.lstat`` / ``os.fstat`` +report the old device / inode for the new file (what Linux shows anyway). +""" + +from __future__ import annotations + +import os +import time + +import pytest + +from stiracore.authority import epoch_store, private_file +from stiracore.authority.latches import LATCH_FILE, LatchStore +from stiracore.authority.lifecycle_events import LOG_FILE, OLD_LOG_FILE, LifecycleLog +from stiracore.grants.store import FILE as GRANT_FILE +from stiracore.grants.store import GrantStore + +INC = "1" * 32 + +posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX file modes (0600)") + + +class _Reused: + """A stat result that shows another device / inode (the reused number).""" + + def __init__(self, real, dev, ino): + self._real, self.st_dev, self.st_ino = real, dev, ino + + def __getattr__(self, name): + return getattr(self._real, name) + + +def _recreate_on_the_same_inode(path, monkeypatch): + """Delete ``path`` and write the same bytes into a new ``0600`` file; from now on the + new file reports the old file's device / inode number (inode reuse).""" + old = os.lstat(path) + raw = path.read_bytes() + time.sleep(0.01) # a coarse ctime clock still ticks (Linux: one jiffy) + path.unlink() + path.write_bytes(raw) + os.chmod(path, 0o600) + new = os.lstat(path) + if (new.st_dev, new.st_ino) == (old.st_dev, old.st_ino): + return raw # the filesystem reused the number itself (Linux) + real_lstat, real_fstat = os.lstat, os.fstat + + def reused(info): + if (info.st_dev, info.st_ino) == (new.st_dev, new.st_ino): + return _Reused(info, old.st_dev, old.st_ino) + return info + + monkeypatch.setattr(os, "lstat", lambda *a, **k: reused(real_lstat(*a, **k))) + monkeypatch.setattr(os, "fstat", lambda *a, **k: reused(real_fstat(*a, **k))) + return raw + + +def test_the_identity_has_the_size_and_the_change_time(tmp_path): + path = tmp_path / "f" + path.write_bytes(b"abc") + info = os.stat(path) + assert epoch_store.identity(info) == ( + info.st_dev, + info.st_ino, + info.st_size, + info.st_ctime_ns, + ) + + +@posix_only +def test_the_writer_records_the_identity_after_its_own_rename(tmp_path): + """The identity ``replace`` returns is what the next read sees (no false alarm from + the writer's own write, flush or rename).""" + for body in (b"one", b"two", b"three"): + written = private_file.replace(tmp_path, "x", body) + loaded = private_file.read(tmp_path / "x", max_bytes=100, prefix="x") + assert loaded.status == "ok" and loaded.identity == written + + +@posix_only +def test_a_latch_store_recreated_on_the_same_inode_is_not_overwritten(tmp_path, monkeypatch): + store = LatchStore(tmp_path) + store.set("kill", incarnation=INC, epoch=1) + raw = _recreate_on_the_same_inode(tmp_path / LATCH_FILE, monkeypatch) + with pytest.raises(OSError): + store.set("unhealthy", incarnation=INC, epoch=2) + assert store.unhealthy_reason == "latch_store_replaced" + assert (tmp_path / LATCH_FILE).read_bytes() == raw + + +@posix_only +def test_a_grant_store_recreated_on_the_same_inode_is_not_overwritten(tmp_path, monkeypatch): + store = GrantStore(tmp_path) + store.load() + assert store.write([]) is True + raw = _recreate_on_the_same_inode(tmp_path / GRANT_FILE, monkeypatch) + assert store.verify() == "grant_store_replaced" + assert store.write([]) is False + assert (tmp_path / GRANT_FILE).read_bytes() == raw + + +@posix_only +def test_a_lifecycle_log_recreated_on_the_same_inode_is_not_appended_to(tmp_path, monkeypatch): + log = LifecycleLog(tmp_path) + log.append(_event()) + raw = _recreate_on_the_same_inode(tmp_path / LOG_FILE, monkeypatch) + with pytest.raises(OSError): + log.append(_event(epoch=4)) + assert log.unhealthy_reason == "lifecycle_log_replaced" + assert (tmp_path / LOG_FILE).read_bytes() == raw + + +@posix_only +def test_an_epoch_store_recreated_on_the_same_inode_differs_from_what_was_written( + tmp_path, monkeypatch +): + record = epoch_store.next_record(None, 1) + written = epoch_store.write(tmp_path, (record,)) + assert epoch_store.load(tmp_path).identity == written + _recreate_on_the_same_inode(tmp_path / epoch_store.EPOCH_FILE, monkeypatch) + loaded = epoch_store.load(tmp_path) + assert loaded.status == "ok" and loaded.identity != written + + +@posix_only +def test_a_rotated_log_still_verifies(tmp_path, monkeypatch): + """The rotation renames the segment (a rename sets st_ctime on Linux): the sealed + segment's identity is taken again, so the next pass does not see a replacement.""" + from stiracore.authority import lifecycle_events + + monkeypatch.setattr(lifecycle_events, "ROTATE_BYTES", 1500) + log = LifecycleLog(tmp_path) + for epoch in range(1, 12): + log.append(_event(epoch=epoch)) + assert (tmp_path / OLD_LOG_FILE).exists() + assert log.verify() is None + + +def _event(epoch=3): + from stiracore.authority.lifecycle_events import make_event + + return make_event( + "latch", + "kill", + "cleared", + "set", + "kill_switch", + incarnation=INC, + epoch=epoch, + ts_wall=1000.0, + ) diff --git a/tests/test_authority_lifecycle.py b/tests/test_authority_lifecycle.py index 54d0ca5..dff6ee5 100644 --- a/tests/test_authority_lifecycle.py +++ b/tests/test_authority_lifecycle.py @@ -781,9 +781,14 @@ def test_an_outbox_replaced_behind_the_recorder_is_not_overwritten(tmp_path): recorder.post(event()) recorder.drain() path = tmp_path / OUTBOX_FILE - other = LifecycleRecorder(tmp_path) # a second writer replaces the file + # A second writer's outbox replaces the file (only the outbox: since #661 an outside + # append to the lifecycle log is caught first, as lifecycle_log_replaced). + other_dir = tmp_path / "other" + other_dir.mkdir() + other = LifecycleRecorder(other_dir) other.post(event(epoch=9)) other.drain() + os.replace(other_dir / OUTBOX_FILE, path) before = path.read_bytes() reported = [] recorder._on_failure = reported.append diff --git a/tests/test_file_tool_observations.py b/tests/test_file_tool_observations.py index bb4821b..e8686ae 100644 --- a/tests/test_file_tool_observations.py +++ b/tests/test_file_tool_observations.py @@ -542,17 +542,34 @@ def to_link(target: Path) -> None: assert hashlib.sha256(other.read_bytes()).hexdigest() not in _everything(result) +_HELD: list = [] + + +def _recreate_with_the_same_bytes(target: Path) -> None: + """Delete the written file and write the same bytes into a new one. The old file is + held open until the test ends, so its inode number stays taken: Linux (ext4, tmpfs) + would otherwise give that number to the new file at once, and "another file with the + same bytes" would not be another inode (#662).""" + _HELD.append(open(target, "rb")) # noqa: SIM115 - closed by the test + target.unlink() + target.write_text("mine", encoding="utf-8") + + @pytest.mark.parametrize( ("swap", "reason"), [ - (lambda t: (t.unlink(), t.write_text("mine", encoding="utf-8")), "target_changed"), + (_recreate_with_the_same_bytes, "target_changed"), (lambda t: t.write_text("changed", encoding="utf-8"), "content_differs"), (lambda t: os.link(t, t.with_name("second-link")), "target_changed"), ], ) def test_other_swaps_are_not_verified(policy, ws, monkeypatch, swap, reason): _swap_then_observe(monkeypatch, swap) - result = _run(_tools(policy)["file_write"], {"path": "swap.txt", "content": "mine"}) + try: + result = _run(_tools(policy)["file_write"], {"path": "swap.txt", "content": "mine"}) + finally: + while _HELD: + _HELD.pop().close() observed = _block(result, OBSERVATION_MARKER) assert observed["sha256"] == hashlib.sha256(b"mine").hexdigest() assert (observed["verified"], observed["verify_error"]) == (False, reason) diff --git a/tests/test_grants_board_read.py b/tests/test_grants_board_read.py index 21303be..d39d398 100644 --- a/tests/test_grants_board_read.py +++ b/tests/test_grants_board_read.py @@ -429,8 +429,19 @@ def test_the_version_check_holds_under_threads(tmp_path): ex._outcomes = deque(maxlen=4 * writes) # both grants' rows stay (the real one keeps 5) + # Every ``pause`` writes, both writers stop at a barrier shared with a prober. The + # prober then reads both boards while no write is open: those reads must succeed and + # must be of one version. The two racing readers run all along, but on a busy 4-vCPU + # runner a write can be open at every one of their reads (all "busy"), so the + # version check is asserted on the prober's deterministic reads (#662). + pause = 100 + stopped, go = threading.Barrier(3, timeout=30), threading.Barrier(3, timeout=30) + def writer(gid): for n in range(1, writes + 1): + if n % pause == 0: + stopped.wait() + go.wait() with ex._board_write(): with ex._lock: ex._last[gid] = {"state": "idle", "number": n, "end": "done", "detail": "", @@ -455,19 +466,40 @@ def reader(k): else: counts["good"] += 1 + probed = [] + + def prober(): + for _ in range(writes // pause): + stopped.wait() + try: + for gid in gids: + for _attempt in range(100_000): # busy only while a racing reader + try: # holds the lock for its snapshot (no write is open) + out = ex.board(gid) + break + except BoardBusy: + continue + last = out["outcomes"][-1]["number"] if out["outcomes"] else 0 + probed.append(out["iteration"]["number"] == last) + finally: + go.wait() + interval = sys.getswitchinterval() sys.setswitchinterval(1e-6) try: threads = [threading.Thread(target=writer, args=(g,)) for g in gids] threads += [threading.Thread(target=reader, args=(k,)) for k in range(2)] + threads.append(threading.Thread(target=prober)) for thread in threads: thread.start() for thread in threads: thread.join(60) finally: sys.setswitchinterval(interval) + assert not any(thread.is_alive() for thread in threads) assert not mixed, mixed[:3] - assert counts["good"] > 0 and counts["good"] + counts["busy"] == 2 * reads + assert probed and all(probed) and len(probed) == 2 * (writes // pause) + assert counts["good"] + counts["busy"] == 2 * reads assert ex.board_seq() % 2 == 0 and ex._board_open == 0 diff --git a/tests/test_grants_executor_server.py b/tests/test_grants_executor_server.py index 9f6ea81..c06b74f 100644 --- a/tests/test_grants_executor_server.py +++ b/tests/test_grants_executor_server.py @@ -126,9 +126,15 @@ def test_a_persons_chat_message_signals_before_the_turn_lock(stage_c_exec): assert executor.preempt_signal.seq == before + 1 -def test_the_shutdown_step_joins_a_running_iteration(stage_c_exec): +def test_the_shutdown_step_joins_a_running_iteration(stage_c_exec, monkeypatch): from grants_exec_helpers import Counter, ScriptedProvider, use + from stiracore.grants import executor as executor_module + + # The call ends by itself (0.5 s hold) after the stop; the 0.5 s grace measured from + # the stop could run out on a slow runner before the rest of the call (the report + # tool, the end) is done, which is the "abandoned" path, not this one (#662). + monkeypatch.setattr(executor_module, "ABANDON_GRACE_SEC", 4.0) app, _data, ws = stage_c_exec executor = app.extensions["grant_executor"] hold, started = threading.Event(), threading.Event() diff --git a/tests/test_ja_hybrid_classifier.py b/tests/test_ja_hybrid_classifier.py index b68c291..ccb006b 100644 --- a/tests/test_ja_hybrid_classifier.py +++ b/tests/test_ja_hybrid_classifier.py @@ -548,11 +548,23 @@ def _load_script(): return mod +def _rounded(value): + """Floats to 12 decimals: Python 3.12's ``sum()`` of floats is compensated (Neumaier), + so a macro average can differ from the committed 3.11 run in the last bit (#662).""" + if isinstance(value, float): + return round(value, 12) + if isinstance(value, dict): + return {key: _rounded(item) for key, item in value.items()} + if isinstance(value, list): + return [_rounded(item) for item in value] + return value + + def test_committed_results_recompute_from_committed_files(): mod = _load_script() committed = json.loads((OUT / "results.json").read_text("utf-8")) again = mod.build_results(DATA, OUT, committed["splits"]) - assert again == committed + assert _rounded(again) == _rounded(committed) assert (OUT / "report.md").read_text("utf-8") == mod.render_report(committed) diff --git a/tests/test_memory_symlink_ancestors.py b/tests/test_memory_symlink_ancestors.py index 8c75ff7..dc56586 100644 --- a/tests/test_memory_symlink_ancestors.py +++ b/tests/test_memory_symlink_ancestors.py @@ -166,3 +166,54 @@ def test_a_path_outside_the_root_is_checked_from_the_filesystem_root(tmp_path, o MemoryStore._reject_symlinks(base / "link" / "note.md", root=root) with pytest.raises(MemorySymlinkError): # ".." never shortens the walk MemoryStore._reject_symlinks(root / ".." / "link" / "note.md", root=root) + + +# --- the swap race: between the path check and the open (#662) ------------------------- +# A read checks the components, then opens without following a link. These two plant the +# link in the window between the two steps (the race the checks are there for), one on the +# entry itself and one on a parent directory. The parent case is why the read walks the +# components again *after* the open: ``O_NOFOLLOW`` refuses a link only as the last +# component, and the identity compare cannot see it either (the open and the stat both +# follow the same swapped parent to the same file). + + +def _swap_when_opened(monkeypatch, path, swap): + """Run ``swap`` once, just before the store opens ``path``.""" + real_open, done = os.open, [] + + def opening(target, *args, **kwargs): + if not done and os.fspath(target) == os.fspath(path): + done.append(swap() or True) + return real_open(target, *args, **kwargs) + + monkeypatch.setattr(os, "open", opening) + return done + + +def test_an_entry_swapped_for_a_link_just_before_the_open_is_refused( + tmp_path, outside, monkeypatch +): + store = _built(tmp_path) + path = store._path(EPISODIC, "e1") + done = _swap_when_opened(monkeypatch, path, lambda: _swap_for_link(path, outside / "note.md")) + + with pytest.raises(MemorySymlinkError): + store.read_text(EPISODIC, "e1") + + assert done # the link really was planted inside the window + _untouched(outside) + + +def test_a_parent_swapped_for_a_link_just_before_the_open_is_refused( + tmp_path, outside, monkeypatch +): + store = _built(tmp_path) + path = store._path(EPISODIC, "e1") + (outside / "e1.md").write_text(f"{SECRET}\n", encoding="utf-8") + done = _swap_when_opened(monkeypatch, path, lambda: _swap_for_link(path.parent, outside)) + + with pytest.raises(MemorySymlinkError): + store.read_text(EPISODIC, "e1") + + assert done + assert sorted(p.name for p in outside.iterdir()) == ["e1.md", "note.md"] diff --git a/tests/test_server_startup_smoke.py b/tests/test_server_startup_smoke.py index 6998cea..01033ff 100644 --- a/tests/test_server_startup_smoke.py +++ b/tests/test_server_startup_smoke.py @@ -198,7 +198,16 @@ def do_GET(self): def log_message(self, *args): pass -HTTPServer(("127.0.0.1", int(os.environ["STIRA_PORT"])), Handler).serve_forever() +class Server(HTTPServer): + def server_bind(self): + # HTTPServer.server_bind asks socket.getfqdn(), a reverse DNS lookup that can take + # longer than the smoke's whole timeout on a CI macOS runner (#662); not needed here. + import socketserver + + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + +Server(("127.0.0.1", int(os.environ["STIRA_PORT"])), Handler).serve_forever() """ @@ -293,7 +302,14 @@ def do_GET(self): def log_message(self, *args): pass - target = HTTPServer(("127.0.0.1", 0), Target) + class Quick(HTTPServer): + def server_bind(self): # no socket.getfqdn() (slow on a CI macOS runner) + import socketserver + + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + target = Quick(("127.0.0.1", 0), Target) Thread(target=target.serve_forever, daemon=True).start() try: smoke, _ = _fake(tmp_path, monkeypatch, "redirect", target.server_address[1]) From b2bfe2ce7ea456de97aa2e588d216f120945cc08 Mon Sep 17 00:00:00 2001 From: Resolver-TNG <45145357+Resolver-TNG@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:15:15 +0900 Subject: [PATCH 2/5] CI trial 3b: helper servers without reverse DNS, SIP diagnosis on a read-only system volume, per-machine python path --- conftest.py | 20 ++++++++++++++++++++ scripts/check_board_svg_browser.mjs | 6 ++++-- tests/fake_bedrock_endpoint.py | 11 ++++++++++- tests/test_server_startup_smoke.py | 2 +- tests/test_write_diagnostics.py | 15 ++++++++++++--- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/conftest.py b/conftest.py index 83bc458..2cd757c 100644 --- a/conftest.py +++ b/conftest.py @@ -26,6 +26,26 @@ finally: del os.environ["STIRA_DATA_DIR"] +# --- Test HTTP helpers: no reverse DNS at bind (#662) --------------------------------- +# ``http.server.HTTPServer.server_bind`` asks ``socket.getfqdn(host)``. On a CI macOS +# runner that reverse lookup can take tens of seconds (it blocked the fake helper servers +# past their 20-30 s timeouts: four smoke failures and "fake model did not start"). Every +# helper here binds 127.0.0.1 and no test reads ``server_name``, so the name is the +# address. Product code does not use ``http.server``; subprocess helpers do the same in +# their own source. +import http.server as _http_server +import socketserver as _socketserver + +if not getattr(_http_server.HTTPServer.server_bind, "_stira_no_reverse_dns", False): + + def _server_bind(self): + _socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + _server_bind._stira_no_reverse_dns = True + _http_server.HTTPServer.server_bind = _server_bind + + if not getattr(FlaskClient.open, "_stira_csrf_wrapped", False): _original_open = FlaskClient.open diff --git a/scripts/check_board_svg_browser.mjs b/scripts/check_board_svg_browser.mjs index 90655e0..8f2de8f 100644 --- a/scripts/check_board_svg_browser.mjs +++ b/scripts/check_board_svg_browser.mjs @@ -85,10 +85,12 @@ async function waitFor(fn, ms = 20000, step = 150) { async function fakeModel() { const p = start(PY, [path.join(ROOT, 'tests/fake_bedrock_endpoint.py'), '0'], { cwd: ROOT }); - let out = ''; + let out = '', err = ''; p.stdout.on('data', (d) => (out += d)); + p.stderr.on('data', (d) => (err += d)); const url = await waitFor(async () => (out.match(/on (http:\/\/127\.0\.0\.1:\d+)/) || [])[1]); - if (!url) throw new Error('fake model did not start'); + // The reason, not just the fact (#662: a bind that waited for reverse DNS looked the same). + if (!url) throw new Error(`fake model did not start (exit ${p.exitCode}): ${out.slice(-400)}${err.slice(-800)}`); return url; } diff --git a/tests/fake_bedrock_endpoint.py b/tests/fake_bedrock_endpoint.py index 6f91200..139ec07 100644 --- a/tests/fake_bedrock_endpoint.py +++ b/tests/fake_bedrock_endpoint.py @@ -39,6 +39,7 @@ import binascii import json +import socketserver import struct import sys import threading @@ -207,7 +208,15 @@ def do_POST(self) -> None: self.end_headers() self.wfile.write(data) - self.server = ThreadingHTTPServer(("127.0.0.1", port), Handler) + class Server(ThreadingHTTPServer): + def server_bind(self): + # No socket.getfqdn() (#662): the reverse lookup for 127.0.0.1 can take + # tens of seconds on a CI macOS runner, and this process is started with a + # timeout by the callers that wait for the URL line below. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + self.server = Server(("127.0.0.1", port), Handler) self.url = f"http://127.0.0.1:{self.server.server_address[1]}" self._thread = threading.Thread(target=self.server.serve_forever, daemon=True) diff --git a/tests/test_server_startup_smoke.py b/tests/test_server_startup_smoke.py index 01033ff..90442bf 100644 --- a/tests/test_server_startup_smoke.py +++ b/tests/test_server_startup_smoke.py @@ -303,7 +303,7 @@ def log_message(self, *args): pass class Quick(HTTPServer): - def server_bind(self): # no socket.getfqdn() (slow on a CI macOS runner) + def server_bind(self): # no socket.getfqdn(): see conftest.py (#662) import socketserver socketserver.TCPServer.server_bind(self) diff --git a/tests/test_write_diagnostics.py b/tests/test_write_diagnostics.py index 7781915..97a134e 100644 --- a/tests/test_write_diagnostics.py +++ b/tests/test_write_diagnostics.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import errno import json import os import stat @@ -235,9 +236,14 @@ def test_volumes_are_tcc_zones_but_volumes_itself_is_not(): @pytest.mark.skipif(not DARWIN, reason="SIP is macOS") @pytest.mark.parametrize("target", ["/System/Library/stira-x.txt", "/usr/bin/stira-x"]) def test_sip_location_is_protected_by_os(target): - """Measured: /System and /usr/bin answer EPERM; their folder carries SF_RESTRICTED.""" - with pytest.raises(PermissionError) as raised: + """Measured: /System and /usr/bin refuse the write and their folder carries + SF_RESTRICTED, so the diagnosis is SIP. Which error the kernel gives depends on the + machine: EPERM from the restricted flag locally, EROFS on a CI macOS runner, where the + signed system volume is mounted read-only (#662). The flag is checked before the mount, + so the diagnosis is the same for both.""" + with pytest.raises(OSError) as raised: open(target, "w") # noqa: SIM115 - the OS refuses; nothing is created + assert raised.value.errno in (errno.EPERM, errno.EROFS) text = f"Error writing file: [Errno {raised.value.errno}] {raised.value.strerror}: '{target}'" failure = classify_write_failure("file_write", {"path": target, "content": "x"}, text) assert (failure.category, failure.remedy) == ("protected_by_os", "sip") @@ -442,7 +448,10 @@ def test_responsible_process_estimates(): launchd = probes.responsible_process( {"XPC_SERVICE_NAME": "com.example.stira"}, "/opt/homebrew/bin/python3", platform="darwin" ) - assert launchd["kind"] == "launchd" and launchd["executable"].endswith("python3") + # The executable is reported resolved, and what /opt/homebrew/bin/python3 points at + # differs per machine (a CI runner: .../Cellar/python@3.15/.../bin/python3.15, #662). + assert launchd["kind"] == "launchd" + assert os.path.basename(launchd["executable"]).startswith("python3") assert probes.responsible_process({}, "/usr/bin/python3", platform="linux")["kind"] == "user" From c4f0049846d9eeeaa48f23543127239ba4606df9 Mon Sep 17 00:00:00 2001 From: Resolver-TNG <45145357+Resolver-TNG@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:43:08 +0900 Subject: [PATCH 3/5] CI trial 3c: the full identity is compared between the path check and the open again, reconcile diagnosis, changelog --- CHANGELOG.md | 12 +++++++++ src/stiracore/authority/epoch_store.py | 5 +--- src/stiracore/authority/private_file.py | 5 +--- tests/test_forgetting_reversible.py | 35 ++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 353adb6..fc70acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -262,6 +262,18 @@ Overview, how to try it and the known limits: docs/autonomy.md. itself, at `agents//memory`, at a layer or `emotion/` directory, at `index.sqlite3` or at an entry file is still refused, also when swapped in later; the per-read check now starts at the root, which halves the `lstat` calls of a read (#296). +- **Memory store (cost of a read)** — the memory root is resolved once when the store is + built and the entry enumeration no longer walks every entry's components again: a read + no longer calls `Path.resolve()` at all (the link walk from the resolved root already + proves the entry is inside it). Measured on the proximity-recall evaluation: 1,171,359 + `lstat` and 117,124 `resolve` calls become 10,097 and 986 (the whole test 27.6 s → 24.7 s + on macOS, where both are cheap; Windows pays far more per call). The no-follow open, the + identity compare and the component walk before and after the open are unchanged, and a + link swapped in just before the open — on the entry or on a parent — is still refused + (two new tests) (#662). +- **PDF extraction** — a memory limit of zero or less is no longer taken as an enforced + limit: Linux accepts a negative `RLIMIT_AS` as "unlimited", so the child parsed where it + should have refused with `pdf_limits_unavailable` (#662). - **Dependencies** — `strands-agents-tools` is held below 0.8.10 (`>=0.8.0,<0.8.10`): the exec bridge refuses a release it was not audited against (0.8.9), so a fresh install got 0.8.10 and no exec-class tools. Deliberate until a newer release is audited (#296). diff --git a/src/stiracore/authority/epoch_store.py b/src/stiracore/authority/epoch_store.py index 7bea0ea..3465b67 100644 --- a/src/stiracore/authority/epoch_store.py +++ b/src/stiracore/authority/epoch_store.py @@ -292,10 +292,7 @@ def load(data_dir: Path) -> LoadResult: opened = os.fstat(fd) except OSError: return LoadResult("bad", reason="epoch_store_unreadable") - # The path and the descriptor are the same file (device / inode): a size or - # change-time difference here is a concurrent append, not a swap; the full - # identity returned below is compared with the writer's own by the caller. - if _unsafe(opened) or (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): + if _unsafe(opened) or identity(opened) != identity(info): return LoadResult("bad", reason="epoch_store_unsafe") if opened.st_size > _MAX_BYTES: return LoadResult("bad", reason="epoch_store_malformed") diff --git a/src/stiracore/authority/private_file.py b/src/stiracore/authority/private_file.py index 79301b5..ec75734 100644 --- a/src/stiracore/authority/private_file.py +++ b/src/stiracore/authority/private_file.py @@ -72,10 +72,7 @@ def read(path: Path, *, max_bytes: int, prefix: str) -> ReadResult: opened = os.fstat(fd) except OSError: return ReadResult("bad", reason=f"{prefix}_unreadable") - # The path and the descriptor are the same file (device / inode): a size or - # change-time difference here is a concurrent append, not a swap; the full - # identity returned below is compared with the writer's own by the caller. - if _unsafe(opened) or (opened.st_dev, opened.st_ino) != (info.st_dev, info.st_ino): + if _unsafe(opened) or identity(opened) != identity(info): return ReadResult("bad", reason=f"{prefix}_unsafe") if opened.st_size > max_bytes: return ReadResult("bad", reason=f"{prefix}_malformed") diff --git a/tests/test_forgetting_reversible.py b/tests/test_forgetting_reversible.py index c9c7011..c4f852a 100644 --- a/tests/test_forgetting_reversible.py +++ b/tests/test_forgetting_reversible.py @@ -521,6 +521,37 @@ def test_audit_failure_after_compression_reports_applied_and_reconciles( assert store.read_text(MemoryLayer.SEMANTIC, first.key) == before[first.key] +def _why_not_reconciled(store, archive_id): + """What :meth:`MemoryMaintenance.reconcile` saw, for a failure message (#662). + + The CI job that runs the whole suite on 3.12 reports ``reconciled == []`` here, while + the test passes alone and with its own file on 3.12 and 3.11 and in the full suite on + 3.11. The branch that is taken depends on the kinds logged for the record and on the + entry's current digest, so the message shows both instead of only the empty list. + """ + from stiracore.control.forgetting_archive import sha256_text + + maintenance = MemoryMaintenance(store) + try: + maintenance.archive.prepare() + kinds = sorted( + event["kind"] + for event in maintenance.archive.logged() + if event["body"].get("id") == archive_id + ) + record = maintenance.archive.load(archive_id) + current = store.read_text(MemoryLayer.SEMANTIC, record.key) + return ( + f"ids={maintenance.archive.ids()} kinds={kinds} key={record.key!r} " + f"digest={None if current is None else sha256_text(current)} " + f"compressed={record.compressed_sha256} original={record.original_sha256}" + ) + except Exception as error: # noqa: BLE001 - a diagnosis, never the result + return f"diagnosis failed: {type(error).__name__}: {error}" + finally: + maintenance.archive.close() + + def test_cli_exits_non_zero_when_the_audit_line_after_a_change_fails(tmp_path, monkeypatch, capsys): base = tmp_path / "memory" config = tmp_path / "agent.json" @@ -544,7 +575,9 @@ def test_cli_exits_non_zero_when_the_audit_line_after_a_change_fails(tmp_path, m assert code == 1 and undo["restored"] is True and undo["refused"] is None assert "injected" in undo["audit_error"] # The interrupted apply was completed in the log by this run before the undo. - assert undo["reconciled"] == [{"id": archive_id, "action": "forget.recovered"}] + assert undo["reconciled"] == [{"id": archive_id, "action": "forget.recovered"}], ( + _why_not_reconciled(store, archive_id) + ) def test_audit_failure_after_restore_reports_restored_and_reconciles(tmp_path, monkeypatch): From 77f5ef04ae82477b8fe96cd2fceea3fd12fe01d1 Mon Sep 17 00:00:00 2001 From: Resolver-TNG <45145357+Resolver-TNG@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:48:36 +0900 Subject: [PATCH 4/5] CI trial 3d: a CSP report in the browser check is the policy working --- scripts/check_board_svg_browser.mjs | 12 +++++++++++- tests/test_board_svg_browser.py | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/check_board_svg_browser.mjs b/scripts/check_board_svg_browser.mjs index 8f2de8f..329517b 100644 --- a/scripts/check_board_svg_browser.mjs +++ b/scripts/check_board_svg_browser.mjs @@ -302,7 +302,17 @@ async function run(label, boardSvg) { report.requests = { total: reqs.length, api: reqs.filter((u) => u.startsWith(`${BASE}/api/`)).length, data: reqs.filter((u) => u.startsWith('data:')).length, other: foreign }; check('no request to any probe URL (whole session)', probes.length === 0, probes); check('no request other than the page APIs while the reply was shown', foreign.length === 0, foreign); - const errors = after.filter((e) => e.method === 'Runtime.exceptionThrown' || (e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error') || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error')); + // A CSP report is the policy doing its work, not a page error: Chrome logs a security + // entry at error level for the hostile SVG's own inline onload ("Executing inline event + // handler violates the following Content Security Policy directive ..." in 156, "Refused + // to execute ..." before), which is exactly what must happen. Those are taken out of the + // error check and checked on their own: every security entry of this window must be such + // a CSP report, and the canary / probe checks above already prove nothing ran (#662). + const cspReport = (e) => e.method === 'Log.entryAdded' && e.params.entry.source === 'security' && /Content Security Policy/.test(e.params.entry.text || ''); + const security = after.filter((e) => e.method === 'Log.entryAdded' && e.params.entry.source === 'security'); + report.cspReports = security.filter(cspReport).map((e) => (e.params.entry.text || '').slice(0, 200)); + check('every security log entry is a CSP report (the policy working)', security.every(cspReport), security.map((e) => (e.params.entry.text || '').slice(0, 200))); + const errors = after.filter((e) => !cspReport(e) && (e.method === 'Runtime.exceptionThrown' || (e.method === 'Runtime.consoleAPICalled' && e.params.type === 'error') || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error'))); report.consoleErrors = errors.map((e) => JSON.stringify(e.params).slice(0, 300)); check('no console error / exception while the reply was shown', errors.length === 0, report.consoleErrors); check('no dialog opened', !events.some((e) => e.method === 'Page.javascriptDialogOpening')); diff --git a/tests/test_board_svg_browser.py b/tests/test_board_svg_browser.py index 9018db7..f89d750 100644 --- a/tests/test_board_svg_browser.py +++ b/tests/test_board_svg_browser.py @@ -47,6 +47,7 @@ "no request to any probe URL (whole session)", "no request other than the page APIs while the reply was shown", "no console error / exception while the reply was shown", + "every security log entry is a CSP report (the policy working)", "no dialog opened", "control: the raw SVG in the DOM does reach the probes (the monitor works)", "log: the reply is drawn without pictures (SVG blocks stay code)", From e05dd02d4a80477b34db8b1f41f06eb20748409b Mon Sep 17 00:00:00 2001 From: Resolver-TNG <45145357+Resolver-TNG@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:33:33 +0900 Subject: [PATCH 5/5] CI trial 3e: the wait-for lower bound tolerates the step overhead of a busy runner --- tests/test_page_check_assertions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_page_check_assertions.py b/tests/test_page_check_assertions.py index 5d84efe..df4bd48 100644 --- a/tests/test_page_check_assertions.py +++ b/tests/test_page_check_assertions.py @@ -572,7 +572,11 @@ def test_wait_for_a_selector_and_a_text_then_a_timeout(form): ], ) steps = result["steps"] - assert steps[1]["ok"] and 250 <= steps[1]["waited_ms"] < 2500, steps[1] + # The page arms its 400 ms timer in the click handler, and the wait step's own clock + # starts after that step returned: on a busy CI macOS runner 234 ms was measured for a + # wait that really did wait (#662). The bound says "it waited for the element instead of + # returning at once", not how fast the harness gets from one step to the next. + assert steps[1]["ok"] and 100 <= steps[1]["waited_ms"] < 2500, steps[1] assert steps[2]["ok"] and steps[2]["waited_ms"] < 500 assert (steps[3]["ok"], steps[3]["code"]) == (False, "timeout") assert 400 <= steps[3]["waited_ms"] < 1500