diff --git a/agent/run_checkpoint_custody.py b/agent/run_checkpoint_custody.py new file mode 100644 index 000000000000..3773380d7013 --- /dev/null +++ b/agent/run_checkpoint_custody.py @@ -0,0 +1,160 @@ +"""Private turn-bound handles; SessionDB remains the sole durable custody owner. + +No database is opened here and no renewal timer is created. Uncertain mutations +retain a handle requiring reconciliation; neither teardown nor a later turn +retries them. Tokens and lease holders never appear in returned summaries. +""" +from dataclasses import dataclass, field +import os +import threading + +from hermes_state_runs import RunCustody, RunCustodyError +from scripts import run_checkpoint_claim as claim_client +from scripts.run_checkpoint_claim import ClaimOutcomeUnknown, ClaimRefusal + + +@dataclass +class _Handle: + holder: str = field(repr=False) + value: RunCustody | None = field(default=None, repr=False) + status: str = "pending" + error: str = "CLAIM_OUTCOME_UNKNOWN" + + +class TurnRunCustody: + """Per-agent capability handles, not a second persistent lease/fence.""" + + def __init__(self, db): + self.db = db + self._lock = threading.RLock() + self._active_holder = None + self._handles: dict[str, _Handle] = {} + + def begin_turn(self, holder): + with self._lock: + if type(holder) is not str or not holder: + raise ClaimRefusal("INVALID_LIVE_BINDING") + if self._active_holder is not None: + raise ClaimRefusal("TURN_ALREADY_ACTIVE") + self._active_holder = holder + + def _active(self, holder): + if not holder or self._active_holder != holder: + raise ClaimRefusal("TURN_NOT_ACTIVE") + + def _handle(self, holder, run_id, generation): + handle = self._handles.get(run_id) + if handle is None: + raise ClaimRefusal("CUSTODY_HANDLE_REQUIRED") + if handle.status != "owned": + raise ClaimOutcomeUnknown(handle.error) + if handle.holder != holder: + raise ClaimRefusal("TURN_NOT_ACTIVE") + if handle.value is None or handle.value.generation != generation: + raise ClaimRefusal("FENCE_MISMATCH") + return handle + + def claim(self, holder, *, session_id, run_id, **kwargs): + with self._lock: + self._active(holder) + if run_id in self._handles: + if self._handles[run_id].status != "owned": + raise ClaimOutcomeUnknown(self._handles[run_id].error) + if self._handles[run_id].value.generation != kwargs.get("expected_generation"): + raise ClaimRefusal("FENCE_MISMATCH") + raise ClaimRefusal("OWNER_ACTIVE") + if len(self._handles) >= 16: + raise ClaimRefusal("CUSTODY_HANDLE_LIMIT") + handle = _Handle(holder) + self._handles[run_id] = handle + + def captured(value): + handle.value = value + + try: + result = claim_client.claim_from_files(self.db, run_id=run_id, + expected_session_id=session_id, expected_lease_holder=holder, + controller_pid=os.getpid(), _on_claim=captured, **kwargs) + except ClaimRefusal: + del self._handles[run_id] + raise + except ClaimOutcomeUnknown as exc: + handle.status, handle.error = "unknown", str(exc) + raise + except BaseException: # Cancellation can follow a committed native mutation. + handle.status, handle.error = "unknown", "CLAIM_OUTCOME_UNKNOWN" + raise ClaimOutcomeUnknown(handle.error) from None + if not isinstance(handle.value, RunCustody): + handle.status, handle.error = "unknown", "CLAIM_HANDLE_UNKNOWN" + raise ClaimOutcomeUnknown(handle.error) + handle.status = "owned" + return result + + @staticmethod + def _summary(value, status): + return {"status": status, "run_id": value.run_id, "generation": value.generation, + "custody_changed": True, "resume_authorized": False, + "downstream_effects_executed": False} + + def _mutate(self, handle, operation, **kwargs): + value = handle.value + # Mark uncertainty before crossing the native effect boundary, including + # cancellation between commit and Python return/readback. + handle.status, handle.error = "pending", "CUSTODY_OUTCOME_UNKNOWN" + try: + updated = operation(value.run_id, owner_token=value.owner_token, + expected_generation=value.generation, **kwargs) + except RunCustodyError as exc: + handle.status = "owned" + raise ClaimRefusal(exc.code) from None + except BaseException: # Cancellation can follow a committed native mutation. + handle.status, handle.error = "unknown", "CUSTODY_OUTCOME_UNKNOWN" + raise ClaimOutcomeUnknown(handle.error) from None + # Retain the native return before readback; an ACK/readback failure must + # not discard the token or be turned into an automatic second mutation. + handle.value = updated + try: + if self.db.read_run_custody(updated.run_id) != updated: + raise ClaimOutcomeUnknown("CUSTODY_READBACK_MISMATCH") + except BaseException: # Cancellation can follow a committed native mutation. + handle.status, handle.error = "unknown", "CUSTODY_READBACK_UNKNOWN" + raise ClaimOutcomeUnknown(handle.error) from None + handle.status = "owned" if updated.disposition == "active" else "released" + return updated + + def refresh(self, holder, *, run_id, expected_generation, ttl_seconds): + with self._lock: + self._active(holder) + handle = self._handle(holder, run_id, expected_generation) + value = self._mutate(handle, self.db.refresh_run_custody, ttl_seconds=ttl_seconds) + return self._summary(value, "refresh_observed") + + def release(self, holder, *, run_id, expected_generation): + with self._lock: + handle = self._handle(holder, run_id, expected_generation) + value = self._mutate(handle, self.db.release_run_custody) + del self._handles[run_id] + return self._summary(value, "release_observed") + + def finish_turn(self, holder): + with self._lock: + if not holder or self._active_holder != holder: + return [] + # Close admission before any cleanup; late claims cannot outlive + # this turn even while the native session lease still exists. + self._active_holder = None + errors = [] + for run_id, handle in list(self._handles.items()): + if handle.holder != holder: + continue + if handle.status == "released": + del self._handles[run_id] + continue + if handle.status == "owned": + try: + self.release(holder, run_id=run_id, expected_generation=handle.value.generation) + except (ClaimRefusal, ClaimOutcomeUnknown) as exc: + handle.status, handle.error = "unknown", str(exc) + if handle.status != "owned" and run_id in self._handles: + errors.append({"run_id": run_id, "status": "unknown", "code": handle.error}) + return errors diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 384a3e8030de..b1a4dd792e46 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -157,7 +157,7 @@ "bippy": "0.5.43", "concurrently": "10.0.4", "cross-env": "10.1.0", - "electron": "40.10.2", + "electron": "41.10.3", "electron-builder": "^26.8.1", "esbuild": "^0.28.1", "eslint": "^9.39.4", @@ -176,7 +176,7 @@ "wait-on": "^9.0.5" }, "build": { - "electronVersion": "40.10.2", + "electronVersion": "41.10.3", "appId": "com.recursiveintell.ares", "productName": "Ares", "executableName": "Ares", diff --git a/hermes_state.py b/hermes_state.py index f11ccb402f26..491ce319240e 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -94,6 +94,7 @@ _PREVIEW_SCAFFOLDED_SQL, ) from hermes_state_portability import SessionPortabilityMixin +from hermes_state_runs import SessionRunCustodyMixin from hermes_state_schema import SessionSchemaMixin from hermes_state_search import SessionSearchMixin @@ -4279,7 +4280,7 @@ def classify_session_status( return SESSION_STATUS_COMPLETE -class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin): +class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin, SessionRunCustodyMixin): """ SQLite-backed session storage with FTS5 search. @@ -14201,8 +14202,15 @@ def _do(conn): def compare_and_set_meta_many( self, items: List[Tuple[str, Optional[str], str]] ) -> bool: - """Atomically publish several meta rows if every preimage matches.""" + """Atomically publish several meta rows if every preimage matches. + + Each normalized key must occur only once. Otherwise two writes can + validate the same preimage and silently overwrite one another within + an apparently successful batch. Reject before entering the transaction. + """ normalized = [(str(key), expected, str(value)) for key, expected, value in items] + if len({key for key, _expected, _value in normalized}) != len(normalized): + raise ValueError("duplicate keys in metadata compare-and-set batch") if not normalized: return True diff --git a/hermes_state_runs.py b/hermes_state_runs.py new file mode 100644 index 000000000000..55f7ff451495 --- /dev/null +++ b/hermes_state_runs.py @@ -0,0 +1,576 @@ +"""Run-scoped checkpoint persistence owned by SessionDB. + +This mixin uses the existing state_meta transaction owner, not a new database, +coordinator, scheduler or filesystem lock. It fences cooperating callers only. +A validated resume is a read result, never a tool/Git/provider authorization. +No external work runs inside a database transaction. Durability is exactly the +SessionDB SQLite/filesystem configuration; no extra power-loss guarantee. +""" +from dataclasses import asdict, dataclass, fields, replace +import hashlib +import json +import math +import os +import re +import secrets +import sqlite3 +import time +from typing import TYPE_CHECKING, Callable, TypeVar + +try: # Match SessionDB's scaffold import boundary without admitting custody. + import psutil +except ImportError: + psutil = None + +T = TypeVar("T") + + +class RunCustodyError(RuntimeError): + """Typed refusal; the stable code is also available independently of prose.""" + + def __init__(self, code): + self.code = code + super().__init__(code) + + +def _text(value): + if type(value) is not str or not value.strip() or len(value) > 1_000_000: + raise RunCustodyError("INVALID_TEXT") + return value + + +def _digest(value): + if type(value) is not str or not re.fullmatch(r"[0-9a-f]{64}", value): + raise RunCustodyError("INVALID_DIGEST") + return value + + +def _integer(value, minimum=1): + if type(value) is not int or value < minimum: + raise RunCustodyError("INVALID_INTEGER") + return value + + +def _run_id(value): + if type(value) is not str or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", value): + raise RunCustodyError("INVALID_RUN_ID") + return value + + +def _ttl(seconds): + if type(seconds) is not int or not 1 <= seconds <= 3600: + raise RunCustodyError("INVALID_TTL") + return time.monotonic_ns() + seconds * 1_000_000_000 + + +def _json(value): + raw = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), allow_nan=False) + if len(raw) > 4_000_000: + raise RunCustodyError("CHECKPOINT_TOO_LARGE") + return raw + + +def _sha(raw): + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _strict_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise RunCustodyError("INTEGRITY_DUPLICATE_KEY") + result[key] = value + return result + + +def _load(raw): + if type(raw) is not str or len(raw) > 4_000_000: + raise RunCustodyError("INTEGRITY_SIZE") + try: + return json.loads(raw, object_pairs_hook=_strict_object) + except (ValueError, TypeError) as exc: + raise RunCustodyError("INTEGRITY_JSON") from exc + + +def _exact_keys(value, cls): + if type(value) is not dict or set(value) != {f.name for f in fields(cls)}: + raise RunCustodyError("INTEGRITY_SCHEMA") + + +def _process_identity(pid): + if psutil is None: + raise RunCustodyError("PROCESS_INSPECTION_UNAVAILABLE") + try: + process = psutil.Process(pid) + if process.status() == psutil.STATUS_ZOMBIE: + return None + return f"{psutil.boot_time().hex()}:{process.create_time().hex()}" + except psutil.NoSuchProcess: + return None + except (psutil.AccessDenied, OSError) as exc: + raise RunCustodyError("PROCESS_UNKNOWN") from exc + + +def _controller(pid): + _integer(pid) + if psutil is None: + raise RunCustodyError("PROCESS_INSPECTION_UNAVAILABLE") + # Permit a short-lived command child of the real controller, never an + # arbitrary caller-chosen unrelated process as an immortal lease holder. + try: + lineage = {os.getpid(), *(p.pid for p in psutil.Process().parents())} + except (psutil.Error, OSError) as exc: + raise RunCustodyError("PROCESS_UNKNOWN") from exc + if pid not in lineage: + raise RunCustodyError("CALLER_NOT_CONTROLLER") + identity = _process_identity(pid) + if identity is None: + raise RunCustodyError("PROCESS_GONE") + return identity + + +def generation_key(run_id, generation): + return f"run-custody:{_run_id(run_id)}:generation:{_integer(generation)}" + + +def _head_key(run_id): + return f"run-custody:{_run_id(run_id)}:head" + + +@dataclass(frozen=True) +class RunCheckpoint: + plan_digest: str + contract_digest: str + source_digest: str + next_action: str + members: tuple[tuple[str, str], ...] + unresolved_effects: tuple[str, ...] + unresolved_findings: tuple[str, ...] + restrictions: tuple[str, ...] + + def __post_init__(self): + for name in ("plan_digest", "contract_digest", "source_digest"): + _digest(getattr(self, name)) + _text(self.next_action) + for name in ("unresolved_effects", "unresolved_findings", "restrictions"): + values = getattr(self, name) + if type(values) is not tuple or len(values) > 10000: + raise RunCustodyError("INVALID_INVENTORY") + for value in values: + _text(value) + if len(set(values)) != len(values): + raise RunCustodyError("DUPLICATE_INVENTORY") + if type(self.members) is not tuple or len(self.members) > 10000: + raise RunCustodyError("INVALID_MEMBERS") + names = set() + for member in self.members: + if type(member) is not tuple or len(member) != 2: + raise RunCustodyError("INVALID_MEMBER") + name, raw = member + _text(name) + _text(raw) + if name in names: + raise RunCustodyError("DUPLICATE_MEMBER") + names.add(name) + _json(asdict(self)) + + @classmethod + def from_dict(cls, value): + _exact_keys(value, cls) + value = dict(value) + for name in ("members", "unresolved_effects", "unresolved_findings", "restrictions"): + if type(value[name]) is not list: + raise RunCustodyError("INTEGRITY_INVENTORY") + if name == "members": + if any(type(member) is not list for member in value[name]): + raise RunCustodyError("INTEGRITY_MEMBERS") + value[name] = tuple(tuple(member) for member in value[name]) + else: + value[name] = tuple(value[name]) + return cls(**value) + + +@dataclass(frozen=True) +class RunCustody: + schema: str + run_id: str + generation: int + predecessor_digest: str | None + owner_token: str + controller_pid: int + process_identity: str + origin_session_id: str + current_session_id: str + historical_goal_digest: str + expires_monotonic_ns: int + disposition: str + checkpoint: RunCheckpoint + + def __post_init__(self): + if self.schema != "SessionDBRunCustodyV1": + raise RunCustodyError("INTEGRITY_SCHEMA") + _run_id(self.run_id) + _integer(self.generation) + if self.generation == 1: + if self.predecessor_digest is not None: + raise RunCustodyError("INTEGRITY_PREDECESSOR") + else: + _digest(self.predecessor_digest) + _digest(self.owner_token) + _integer(self.controller_pid) + for name in ("process_identity", "origin_session_id", "current_session_id"): + _text(getattr(self, name)) + _digest(self.historical_goal_digest) + _integer(self.expires_monotonic_ns) + if self.disposition not in {"active", "released"}: + raise RunCustodyError("INTEGRITY_DISPOSITION") + if type(self.checkpoint) is not RunCheckpoint: + raise RunCustodyError("INVALID_CHECKPOINT") + + @classmethod + def from_dict(cls, value): + _exact_keys(value, cls) + return cls(**{**value, "checkpoint": RunCheckpoint.from_dict(value["checkpoint"])}) + + +def _preserve(old, new): + # No settlement/authority-change API is invented here. Those owners must + # supply a separately reviewed transition before obligations can be removed. + for name in ("unresolved_effects", "unresolved_findings", "restrictions"): + if not set(getattr(old, name)) <= set(getattr(new, name)): + raise RunCustodyError("OMITTED_OBLIGATION") + if not set(dict(old.members)) <= set(dict(new.members)): + raise RunCustodyError("OMITTED_MEMBER") + if not set(old.members) <= set(new.members): + raise RunCustodyError("SUBSTITUTED_MEMBER") + if old.source_digest != new.source_digest: + raise RunCustodyError("SOURCE_MISMATCH") + if old.plan_digest != new.plan_digest or old.contract_digest != new.contract_digest: + raise RunCustodyError("PLAN_CONTRACT_MISMATCH") + + +_REFRESH_SCHEMA = "SessionDBRunCustodyRefreshV1" + + +def _refresh_parts(document): + """Decode only the explicit compact storage envelope, never widen V1.""" + if (type(document) is not dict or + set(document) != {"schema", "custody", "checkpoint_reference"} or + document["schema"] != _REFRESH_SCHEMA): + raise RunCustodyError("INTEGRITY_REFRESH_SCHEMA") + metadata, reference = document["custody"], document["checkpoint_reference"] + if (type(metadata) is not dict or + set(metadata) != {f.name for f in fields(RunCustody)} - {"checkpoint"} or + type(reference) is not dict or set(reference) != {"generation", "digest"}): + raise RunCustodyError("INTEGRITY_REFRESH_SCHEMA") + _integer(reference["generation"]) + _digest(reference["digest"]) + return metadata, reference + + +def _decode_run_record(run_id, generation, expected_digest, read_value): + """Materialize one V1 logical value from a full or versioned compact record. + + Compact records refer directly to a full immutable checkpoint, never to + another compact record. The immediate predecessor binds that same reference + and ownership. Each historical read validates its own link without recursive + expansion of an arbitrarily long refresh chain. + """ + def load(number, digest): + raw = read_value(generation_key(run_id, number)) + if raw is None or _sha(raw) != digest: + raise RunCustodyError("INTEGRITY_MEMBER") + document = _load(raw) + if type(document) is not dict: + raise RunCustodyError("INTEGRITY_SCHEMA") + return document + + document = load(generation, expected_digest) + if document.get("schema") == "SessionDBRunCustodyV1": + value = RunCustody.from_dict(document) + else: + metadata, reference = _refresh_parts(document) + if reference["generation"] >= generation: + raise RunCustodyError("INTEGRITY_REFRESH_REFERENCE") + anchor_document = load(reference["generation"], reference["digest"]) + anchor = RunCustody.from_dict(anchor_document) + if anchor.run_id != run_id or anchor.generation != reference["generation"]: + raise RunCustodyError("INTEGRITY_REFRESH_REFERENCE") + # Use validated wire lists, not dataclass tuples: V1's strict decoder + # must continue rejecting non-JSON inventory shapes. + checkpoint = anchor_document["checkpoint"] + value = RunCustody.from_dict({**metadata, "checkpoint": checkpoint}) + prior_document = load(generation - 1, value.predecessor_digest) + if prior_document.get("schema") == "SessionDBRunCustodyV1": + prior = RunCustody.from_dict(prior_document) + prior_reference = {"generation": generation - 1, "digest": value.predecessor_digest} + else: + prior_metadata, prior_reference = _refresh_parts(prior_document) + prior = RunCustody.from_dict({**prior_metadata, "checkpoint": checkpoint}) + if (prior_reference != reference or prior.run_id != run_id or + prior.generation != generation - 1 or value.disposition != "active"): + raise RunCustodyError("INTEGRITY_REFRESH_REFERENCE") + for field in fields(RunCustody): + if field.name not in {"generation", "predecessor_digest", "expires_monotonic_ns"}: + if getattr(prior, field.name) != getattr(value, field.name): + raise RunCustodyError("INTEGRITY_REFRESH_OWNERSHIP") + if value.run_id != run_id or value.generation != generation: + raise RunCustodyError("INTEGRITY_IDENTITY") + return value + + +class SessionRunCustodyMixin: + """Typed run APIs on SessionDB's existing transaction/key-value owner.""" + + if TYPE_CHECKING: + # Host interface only; SessionDB provides both concrete operations. + def get_meta(self, key: str) -> str | None: ... + def _execute_write(self, fn: Callable[[sqlite3.Connection], T], + patience_s: float | None = None) -> T: ... + def _session_turn_lease_key_on_conn(self, conn: sqlite3.Connection, + session_id: str) -> str: ... + + def _read_run_head(self, run_id): + raw = self.get_meta(_head_key(run_id)) + if raw is None: + return None, None + head = _load(raw) + if type(head) is not dict or set(head) != {"generation", "digest"}: + raise RunCustodyError("INTEGRITY_HEAD") + _integer(head["generation"]) + _digest(head["digest"]) + # The immutable member can be read after releasing the head read lock: + # a legitimate publisher never changes/removes an existing generation. + value = self._read_run_member(run_id, head["generation"], head["digest"]) + return raw, value + + def _read_run_member(self, run_id, generation, expected_digest): + return _decode_run_record(run_id, generation, expected_digest, self.get_meta) + + def read_run_custody(self, run_id): + """Read committed native metadata; does not authorize continuation.""" + return self._read_run_head(run_id)[1] + + def read_run_checkpoint(self, run_id, *, generation): + _integer(generation) + value = self.read_run_custody(run_id) + if value is None or generation > value.generation: + raise RunCustodyError("GENERATION_NOT_FOUND") + if value.generation - generation > 10000: + raise RunCustodyError("HISTORY_BOUND_EXCEEDED") + while value.generation > generation: + value = self._read_run_member(run_id, value.generation - 1, value.predecessor_digest) + return value + + def _check_run_claim_bindings(self, conn, value, lease_holder): + """Native checks on the admitted connection, not external authority.""" + session = conn.execute("SELECT ended_at,end_reason FROM sessions WHERE id=?", + (value.current_session_id,)).fetchone() + if session is None: + raise RunCustodyError("SESSION_MISMATCH") + if session["ended_at"] is not None or session["end_reason"] is not None: + raise RunCustodyError("SESSION_NOT_CURRENT") + root = self._session_turn_lease_key_on_conn(conn, value.current_session_id) + lease = conn.execute("SELECT holder,expires_at FROM session_turn_leases " + "WHERE conversation_id=?", (root,)).fetchone() + if lease is None or lease["holder"] != lease_holder: + raise RunCustodyError("LEASE_MISMATCH") + try: + expiry = float(lease["expires_at"]) + except (TypeError, ValueError, OverflowError) as exc: + raise RunCustodyError("LEASE_MISMATCH") from exc + if not math.isfinite(expiry) or expiry <= time.time(): + raise RunCustodyError("LEASE_MISMATCH") + goal_key = dict(value.checkpoint.members).get("historical-goal-key") + if not goal_key: + raise RunCustodyError("HISTORICAL_GOAL_BINDING_MISSING") + goal = conn.execute("SELECT value FROM state_meta WHERE key=?", (goal_key,)).fetchone() + if goal is None or type(goal[0]) is not str or _sha(goal[0]) != value.historical_goal_digest: + raise RunCustodyError("HISTORICAL_GOAL_MISMATCH") + + def _commit_run(self, expected_head, value, *, require_live_owner=True, + predecessor_expires_ns=None, claim_lease_holder=None, + refresh_reference=None): + document = asdict(value) + if refresh_reference is not None: + document.pop("checkpoint") + document = {"schema": _REFRESH_SCHEMA, "custody": document, + "checkpoint_reference": refresh_reference} + raw = _json(document) + head = _json({"generation": value.generation, "digest": _sha(raw)}) + key = _head_key(value.run_id) + member_key = generation_key(value.run_id, value.generation) + + def write(conn): + row = conn.execute("SELECT value FROM state_meta WHERE key=?", (key,)).fetchone() + if (None if row is None else row[0]) != expected_head: + raise RunCustodyError("FENCE_MISMATCH") + # Recheck after waiting for SQLite admission, not only before it. + if require_live_owner: + if predecessor_expires_ns is not None and time.monotonic_ns() >= predecessor_expires_ns: + raise RunCustodyError("OWNER_EXPIRED") + if _controller(value.controller_pid) != value.process_identity: + raise RunCustodyError("STALE_PROCESS") + if time.monotonic_ns() >= value.expires_monotonic_ns: + raise RunCustodyError("OWNER_EXPIRED") + if conn.execute("SELECT 1 FROM state_meta WHERE key=?", (member_key,)).fetchone(): + raise RunCustodyError("INTEGRITY_GENERATION_EXISTS") + if claim_lease_holder is not None: + self._check_run_claim_bindings(conn, value, claim_lease_holder) + if refresh_reference is not None: + def read_value(record_key): + if record_key == member_key: + return raw + row = conn.execute("SELECT value FROM state_meta WHERE key=?", (record_key,)).fetchone() + return None if row is None else row[0] + if _decode_run_record(value.run_id, value.generation, _sha(raw), read_value) != value: + raise RunCustodyError("INTEGRITY_REFRESH_VALUE") + conn.execute("INSERT INTO state_meta(key,value) VALUES(?,?)", (member_key, raw)) + conn.execute("INSERT INTO state_meta(key,value) VALUES(?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, head)) + self._execute_write(write) + return value + + def claim_run_custody(self, run_id, *, expected_generation, checkpoint, + origin_session_id, current_session_id, + historical_goal_digest, ttl_seconds=300, controller_pid=None): + """Low-level metadata claim; does not establish session/goal bindings.""" + raw, value = self._prepare_run_claim(run_id, expected_generation=expected_generation, + checkpoint=checkpoint, origin_session_id=origin_session_id, + current_session_id=current_session_id, historical_goal_digest=historical_goal_digest, + ttl_seconds=ttl_seconds, controller_pid=controller_pid) + return self._commit_run(raw, value) + + def claim_run_custody_checked(self, run_id, *, lease_holder, expected_generation, + checkpoint, origin_session_id, current_session_id, + historical_goal_digest, ttl_seconds=300, controller_pid=None): + """Claim with native session/lease/goal checks in the commit transaction. + + The historical-goal key comes from the checkpoint's bound member. + Caller-observed file digests and goal semantics are not verified here. + This is not downstream effect authorization or filesystem atomicity; + other mutation methods remain low-level custody primitives. + """ + _text(lease_holder) # Invalid/missing bindings must never disable checks. + raw, value = self._prepare_run_claim(run_id, expected_generation=expected_generation, + checkpoint=checkpoint, origin_session_id=origin_session_id, + current_session_id=current_session_id, historical_goal_digest=historical_goal_digest, + ttl_seconds=ttl_seconds, controller_pid=controller_pid) + return self._commit_run(raw, value, claim_lease_holder=lease_holder) + + def _prepare_run_claim(self, run_id, *, expected_generation, checkpoint, + origin_session_id, current_session_id, + historical_goal_digest, ttl_seconds, controller_pid): + _integer(expected_generation, 0) + if type(checkpoint) is not RunCheckpoint: + raise RunCustodyError("INVALID_CHECKPOINT") + pid = os.getpid() if controller_pid is None else controller_pid + identity = _controller(pid) + raw, old = self._read_run_head(run_id) + if (0 if old is None else old.generation) != expected_generation: + raise RunCustodyError("FENCE_MISMATCH") + if old is not None: + if old.disposition == "active" and _process_identity(old.controller_pid) == old.process_identity: + # Expiry alone never grants a competing writer takeover. + raise RunCustodyError("OWNER_ACTIVE") + if (origin_session_id != old.origin_session_id or + historical_goal_digest != old.historical_goal_digest): + raise RunCustodyError("HISTORY_MISMATCH") + if checkpoint != old.checkpoint: + raise RunCustodyError("TAKEOVER_REQUIRES_EXACT_CHECKPOINT") + value = RunCustody("SessionDBRunCustodyV1", run_id, expected_generation + 1, + None if old is None else _load(raw)["digest"], secrets.token_hex(32), pid, + identity, origin_session_id, current_session_id, historical_goal_digest, + _ttl(ttl_seconds), "active", checkpoint) + return raw, value + + def _owned_run(self, run_id, owner_token, expected_generation, *, allow_expired=False): + _integer(expected_generation) + raw, old = self._read_run_head(run_id) + if old is None or old.generation != expected_generation: + raise RunCustodyError("FENCE_MISMATCH") + if old.disposition != "active" or not secrets.compare_digest(old.owner_token, owner_token): + raise RunCustodyError("STALE_OWNER") + if _controller(old.controller_pid) != old.process_identity: + raise RunCustodyError("STALE_PROCESS") + if not allow_expired and time.monotonic_ns() >= old.expires_monotonic_ns: + raise RunCustodyError("OWNER_EXPIRED") + return raw, old + + def publish_run_checkpoint(self, run_id, *, owner_token, expected_generation, + expected_source_digest, checkpoint, ttl_seconds=300): + raw, old = self._owned_run(run_id, owner_token, expected_generation) + if expected_source_digest != old.checkpoint.source_digest: + raise RunCustodyError("SOURCE_MISMATCH") + if type(checkpoint) is not RunCheckpoint: + raise RunCustodyError("INVALID_CHECKPOINT") + _preserve(old.checkpoint, checkpoint) + value = replace(old, generation=old.generation + 1, predecessor_digest=_load(raw)["digest"], + checkpoint=checkpoint, expires_monotonic_ns=_ttl(ttl_seconds)) + return self._commit_run(raw, value, predecessor_expires_ns=old.expires_monotonic_ns) + + def transition_run_source(self, run_id, *, owner_token, expected_generation, + expected_source_digest, new_source_digest, + observation_ref, observation_digest, ttl_seconds=300): + """Record an explicit caller-observed source change, not permission. + + The opaque observation reference is bounded text; its digest is bound + but not fetched or verified here. The controller must independently + observe the old/new source and verify the referenced evidence. Ordinary + publication and resume checks retain their strict source equality. + """ + raw, old = self._owned_run(run_id, owner_token, expected_generation) + if expected_source_digest != old.checkpoint.source_digest: + raise RunCustodyError("SOURCE_MISMATCH") + _digest(new_source_digest) + _text(observation_ref) + _digest(observation_digest) + if new_source_digest == expected_source_digest: + raise RunCustodyError("SOURCE_UNCHANGED") + member = (f"source-transition:{old.generation + 1}", _json({ + "schema": "RunSourceTransitionV1", + "old_source_digest": expected_source_digest, + "new_source_digest": new_source_digest, + "observation_ref": observation_ref, + "observation_digest": observation_digest, + })) + # Construct, never accept, the new checkpoint: every other field is + # retained exactly. Duplicate names are refused by RunCheckpoint. + checkpoint = replace(old.checkpoint, source_digest=new_source_digest, + members=old.checkpoint.members + (member,)) + value = replace(old, generation=old.generation + 1, + predecessor_digest=_load(raw)["digest"], checkpoint=checkpoint, + expires_monotonic_ns=_ttl(ttl_seconds)) + return self._commit_run(raw, value, predecessor_expires_ns=old.expires_monotonic_ns) + + def refresh_run_custody(self, run_id, *, owner_token, expected_generation, ttl_seconds=300): + raw, old = self._owned_run(run_id, owner_token, expected_generation) + member = self.get_meta(generation_key(run_id, old.generation)) + old_digest = _load(raw)["digest"] + if member is None or _sha(member) != old_digest: + raise RunCustodyError("INTEGRITY_MEMBER") + document = _load(member) + if document.get("schema") == "SessionDBRunCustodyV1": + reference = {"generation": old.generation, "digest": old_digest} + else: + _, reference = _refresh_parts(document) + value = replace(old, generation=old.generation + 1, predecessor_digest=old_digest, + expires_monotonic_ns=_ttl(ttl_seconds)) + return self._commit_run(raw, value, predecessor_expires_ns=old.expires_monotonic_ns, + refresh_reference=reference) + + def release_run_custody(self, run_id, *, owner_token, expected_generation): + raw, old = self._owned_run(run_id, owner_token, expected_generation, allow_expired=True) + value = replace(old, generation=old.generation + 1, predecessor_digest=_load(raw)["digest"], + disposition="released") + return self._commit_run(raw, value, require_live_owner=False) + + def validate_run_resume(self, run_id, *, owner_token, expected_generation, + source_digest, plan_digest, contract_digest): + """Caller must independently observe inputs; this performs no effects.""" + _raw, old = self._owned_run(run_id, owner_token, expected_generation) + if source_digest != old.checkpoint.source_digest: + raise RunCustodyError("SOURCE_MISMATCH") + if (plan_digest, contract_digest) != (old.checkpoint.plan_digest, old.checkpoint.contract_digest): + raise RunCustodyError("PLAN_CONTRACT_MISMATCH") + return old diff --git a/package-lock.json b/package-lock.json index e96b38bc32ba..e9bb2269813f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -238,7 +238,7 @@ "bippy": "0.5.43", "concurrently": "10.0.4", "cross-env": "10.1.0", - "electron": "40.10.2", + "electron": "41.10.3", "electron-builder": "^26.8.1", "esbuild": "^0.28.1", "eslint": "^9.39.4", @@ -265,7 +265,6 @@ "resolved": "https://registry.npmjs.org/@assistant-ui/react/-/react-0.14.24.tgz", "integrity": "sha512-DHUEbJfn3EeApiLXJp6pZfwzGUwQ7aAoGeUfs8bmo1G9uAkRlxF1RKr7MwM/oVSUt+ixSsfWey+OuHJi5gtKCQ==", "license": "MIT", - "peer": true, "dependencies": { "@assistant-ui/core": "^0.2.19", "@assistant-ui/store": "^0.2.19", @@ -551,7 +550,6 @@ "resolved": "https://registry.npmjs.org/@assistant-ui/store/-/store-0.2.22.tgz", "integrity": "sha512-RdUFLOFJ3ZwIoOZYrRvps3OAODwnWoUAuNfqbROKrj5qtlQsIJRqxUNdLTjcf4AimhkjCwOaFacGHHm9lE4qBw==", "license": "MIT", - "peer": true, "dependencies": { "use-effect-event": "^2.0.3" }, @@ -571,7 +569,6 @@ "resolved": "https://registry.npmjs.org/@assistant-ui/tap/-/tap-0.9.8.tgz", "integrity": "sha512-pajYWHwvsApAyEtK/TDSr2J2O6/fV1M+ZOmmkKM8apoE6JrC6F8IVKCU1b9rMkB0RidM2lrQxFpN9Ujw3mKEGQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" @@ -1836,7 +1833,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1885,7 +1881,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1907,7 +1902,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1944,6 +1938,16 @@ "react": ">=16.8.0" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", @@ -1970,9 +1974,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2104,35 +2108,48 @@ } }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" + "undici": "^7.24.4" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" } }, "node_modules/@electron/notarize": { @@ -2844,9 +2861,9 @@ "license": "MIT" }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2942,9 +2959,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3674,7 +3691,6 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", - "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -5283,7 +5299,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -5752,7 +5767,6 @@ "resolved": "https://registry.npmjs.org/@streamdown/code/-/code-1.1.1.tgz", "integrity": "sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "shiki": "^3.19.0" }, @@ -5842,7 +5856,6 @@ "resolved": "https://registry.npmjs.org/@streamdown/math/-/math-1.0.2.tgz", "integrity": "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", @@ -7464,7 +7477,6 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -7547,17 +7559,6 @@ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", @@ -8473,7 +8474,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8967,7 +8967,6 @@ "resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.41.tgz", "integrity": "sha512-lrH9USOoNaAWAAbujeHa/PEiWoqNQHjIPIAeeA3y3GUXOdlmTjIyR/7GbEZBKbHhm1Lyt7aAYKvdxHFkhuEbJw==", "license": "MIT", - "peer": true, "dependencies": { "assistant-stream": "^0.3.38" } @@ -9308,7 +9307,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -10074,7 +10072,6 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.2.tgz", "integrity": "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -10475,7 +10472,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -10870,9 +10866,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -10899,7 +10895,6 @@ "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", @@ -11141,22 +11136,22 @@ } }, "node_modules/electron": { - "version": "40.10.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.10.2.tgz", - "integrity": "sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==", + "version": "41.10.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.10.3.tgz", + "integrity": "sha512-MJuSODPw8siv/I8JjhctW/cS/XNldwI4gLRyyWZx6QkoZJUDgbEvitp7IVOnGrHENTQb6Udo+zMpKhFnhlIhdg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { @@ -11816,7 +11811,6 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11881,7 +11875,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12074,9 +12067,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -12194,9 +12187,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -12392,27 +12385,6 @@ "node": ">=0.10.0" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12668,21 +12640,6 @@ } } }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -12923,9 +12880,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -13044,8 +13001,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/hachure-fill": { "version": "0.5.2", @@ -13699,7 +13655,6 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -14372,7 +14327,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -14404,9 +14358,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -14432,7 +14386,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -14652,7 +14605,6 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -16168,7 +16120,6 @@ "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", "license": "MIT", - "peer": true, "dependencies": { "framer-motion": "^12.42.2", "tslib": "^2.4.0" @@ -16240,7 +16191,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -16789,13 +16739,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -17493,7 +17436,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -17575,7 +17517,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19220,8 +19161,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", @@ -19306,8 +19246,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tiny-async-pool": { "version": "1.3.0", @@ -19511,7 +19450,6 @@ "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -19652,7 +19590,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20320,7 +20257,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -20848,19 +20784,6 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, - "node_modules/yauzl": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", - "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -20879,7 +20802,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -20902,7 +20824,6 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.20.0" }, @@ -20943,335 +20864,132 @@ "@types/plist": "3.0.5", "plist": "3.1.1", "typescript": "6.0.3", - "vitest": "4.1.10" + "vitest": "4.1.11" } }, - "tests-js/node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", + "ui-tui": { + "name": "hermes-tui", + "version": "0.0.1", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@hermes/ink": "file:./packages/hermes-ink", + "@hermes/shared": "file:../apps/shared", + "@nanostores/react": "1.1.0", + "ink-text-input": "6.0.0", + "nanostores": "1.4.2", + "react": "19.2.7", + "undici": "6.28.0", + "unicode-animations": "1.0.3" }, - "funding": { - "url": "https://opencollective.com/vitest" + "devDependencies": { + "@types/node": "22.20.1", + "@types/react": "19.2.17", + "esbuild": "0.28.1", + "prettier": "3.9.5", + "tsx": "4.23.1", + "typescript": "6.0.3", + "vitest": "4.1.11" } }, - "tests-js/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "ui-tui/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "tests-js/node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "ui-tui/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "tests-js/node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "ui-tui/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "tests-js/node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "ui-tui/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "tests-js/node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "ui-tui/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "tests-js/node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "ui-tui/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "tests-js/node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "ui-tui": { - "name": "hermes-tui", - "version": "0.0.1", - "dependencies": { - "@hermes/ink": "file:./packages/hermes-ink", - "@hermes/shared": "file:../apps/shared", - "@nanostores/react": "1.1.0", - "ink-text-input": "6.0.0", - "nanostores": "1.4.2", - "react": "19.2.7", - "undici": "6.28.0", - "unicode-animations": "1.0.3" - }, - "devDependencies": { - "@types/node": "22.20.1", - "@types/react": "19.2.17", - "esbuild": "0.28.1", - "prettier": "3.9.5", - "tsx": "4.23.1", - "typescript": "6.0.3", - "vitest": "4.1.10" - } - }, - "ui-tui/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "ui-tui/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, "ui-tui/node_modules/@esbuild/freebsd-arm64": { @@ -21614,119 +21332,6 @@ "node": ">=18" } }, - "ui-tui/node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "ui-tui/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "ui-tui/node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "ui-tui/node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "ui-tui/node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "ui-tui/node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "ui-tui/node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "ui-tui/node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -21804,96 +21409,6 @@ "fsevents": "~2.3.3" } }, - "ui-tui/node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, "ui-tui/packages/hermes-ink": { "name": "@hermes/ink", "version": "0.0.1", @@ -21970,7 +21485,7 @@ "three": "0.180.0", "typescript": "6.0.3", "vite": "8.2.0", - "vitest": "4.1.10" + "vitest": "4.1.11" } }, "web/node_modules/@babel/core": { @@ -22005,126 +21520,12 @@ "url": "https://opencollective.com/babel" } }, - "web/node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "web/node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "web/node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "web/node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "web/node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "web/node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "web/node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "web/node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -22196,96 +21597,6 @@ "optional": true } } - }, - "web/node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } } } } diff --git a/package.json b/package.json index d2b251c45ccd..52040eb809a8 100644 --- a/package.json +++ b/package.json @@ -49,14 +49,14 @@ "protobufjs": "^8.7.1", "brace-expansion": "5.0.9", "minimatch@3.1.5": { - "brace-expansion": "1.1.12" + "brace-expansion": "1.1.18" }, "mermaid": "11.16.1", "dompurify": "3.4.13", "ip-address": "10.3.1", "nanoid@^3": "3.3.18", "nanoid@^6": "6.0.0", - "js-yaml@^4": "4.3.1", + "js-yaml@^4": "4.3.2", "undici@^6": "6.28.0", "undici@^7": "7.29.0", "postcss": "8.5.23", @@ -71,7 +71,7 @@ "esbuild@0.28.1": true, "esbuild@0.28.2": true, "node-pty@1.1.0": true, - "electron@40.10.2": true, + "electron@41.10.3": true, "fsevents@2.3.2": true, "fsevents@2.3.3": true } diff --git a/pyproject.toml b/pyproject.toml index b30ee1e5661e..08a3663851f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,7 +184,7 @@ modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] vercel = ["vercel==0.7.2"] hindsight = ["hindsight-client==0.6.1"] -dev = ["debugpy==1.8.20", "pytest==9.1.1", "pytest-asyncio==1.3.0", "mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==83.0.0"] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83) +dev = ["debugpy==1.8.20", "pytest==9.1.1", "pytest-asyncio==1.3.0", "mcp==2.0.0", "httpx2==2.12.0", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==83.0.0"] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83) messaging = ["python-telegram-bot[webhooks]==22.8", "discord.py[voice]==2.7.1", "aiohttp==3.14.3", "brotlicffi==1.2.0.1", "slack-bolt==1.30.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.3: prior CVEs + GHSA-cq5v-8q36-5273/GHSA-mfx4-hv73-q22v/GHSA-mq44-7p77-q5h7 cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.30.0", "slack-sdk==3.43.0", "aiohttp==3.14.3"] @@ -256,7 +256,7 @@ pty = [] # resolution. Hermes' own `httpx[socks]==0.28.1` in [dependencies] is # unaffected — the two distributions install side by side under different # module names. -mcp = ["mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1"] # starlette: CVE-2026-48710 +mcp = ["mcp==2.0.0", "httpx2==2.12.0", "starlette==1.3.1"] # starlette: CVE-2026-48710 # Backwards-compatible no-op alias. Relay is a core dependency on supported # wheel targets and intentionally unavailable on other platforms. nemo-relay = [] @@ -267,7 +267,7 @@ teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.3"] # aiohttp 3.14.3: # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk # to it, which is already provided by the `mcp` extra. -computer-use = ["mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1"] # starlette: CVE-2026-48710 +computer-use = ["mcp==2.0.0", "httpx2==2.12.0", "starlette==1.3.1"] # starlette: CVE-2026-48710 acp = ["agent-client-protocol==0.9.0"] # mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version. # The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious @@ -436,12 +436,17 @@ py-modules = [ "hermes_state", "hermes_state_common", "hermes_state_portability", + "hermes_state_runs", "hermes_state_schema", "hermes_state_search", "hermes_time", "hermes_logging", "utils", "mcp_serve", + # Runtime RPC imports: include only these private namespace modules, not + # the unrelated operational scripts in the same source directory. + "scripts.run_checkpoint_claim", + "scripts.run_checkpoint_resume", ] [tool.setuptools.packages.find] diff --git a/run_agent.py b/run_agent.py index 0d585070b2d2..9b6bbff7d11a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -8575,6 +8575,7 @@ def run_conversation( relay_lease = None relay_turn = None durable_turn_lease = None + turn_run_custody = None durable_turn_lease_stop = None durable_turn_lease_thread = None durable_turn_lease_activity_lock = threading.Lock() @@ -8756,6 +8757,14 @@ def _on_session_turn_lease_wait(elapsed: float) -> None: # the agent attr so a late flush after reclaim is fenced in # the same SQLite write transaction as the transcript insert. durable_turn_lease = _durable_holder + from agent.run_checkpoint_custody import TurnRunCustody + turn_run_custody = getattr(self, "_run_checkpoint_custody", None) + if turn_run_custody is None: + turn_run_custody = TurnRunCustody(_turn_db) + self._run_checkpoint_custody = turn_run_custody + if turn_run_custody.db is not _turn_db: + raise RuntimeError("run checkpoint store owner changed") + turn_run_custody.begin_turn(_durable_holder) self._active_session_turn_lease_holder = _durable_holder self._active_session_turn_lease_ttl_seconds = _lease_ttl if _lease_waited: @@ -8969,6 +8978,15 @@ def _interrupt_turn(message: str) -> None: # late interrupt does not survive into the next turn. _clear_durable_turn_lease_interrupt() if durable_turn_lease is not None: + if turn_run_custody is not None: + try: + cleanup_errors = turn_run_custody.finish_turn(durable_turn_lease) + if cleanup_errors: + self._run_checkpoint_cleanup_errors = cleanup_errors + logger.error("Run checkpoint cleanup requires reconciliation: %s", cleanup_errors) + self._emit_warning("Run checkpoint cleanup is unresolved; reconcile native custody before continuing that run.") + except Exception: + logger.error("Run checkpoint cleanup outcome unknown; reconciliation required") try: _turn_db.release_session_turn_lease( session_id, durable_turn_lease diff --git a/scripts/run_checkpoint_claim.py b/scripts/run_checkpoint_claim.py new file mode 100644 index 000000000000..69104feabf11 --- /dev/null +++ b/scripts/run_checkpoint_claim.py @@ -0,0 +1,83 @@ +"""Private file-bound client of SessionDB's checked claim API. + +The coordinator supplies its already-open writable SessionDB. This module never +opens, migrates, repairs, closes or substitutes a store, acquires a session lease, +or executes downstream work. Caller-supplied digests bind observations, not +permission. Files are observed before the native transaction, not atomically +with it. Unknown write/readback outcomes require reconciliation, never retry. +""" +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint, RunCustodyError +from scripts.run_checkpoint_resume import ( + BoundFileReader, ResumeRefusal, exact_keys, strict_json, verify_checkpoint_files, +) + + +class ClaimRefusal(RuntimeError): + """Stable code: preflight or native admission refused this invocation.""" + + +class ClaimOutcomeUnknown(RuntimeError): + """A write may have committed; reconcile native head before any new action.""" + + +def claim_from_files(db, *, run_id, expected_generation, request_path, + expected_request_digest, origin_session_id, + historical_goal_digest, controller_pid, ttl_seconds, + expected_session_id=None, expected_lease_holder=None, _on_claim=None): + """Claim through the native owner and compare its exact persisted value. + + An initial claim has caller-selected inventory; this does not authenticate + its completeness. Takeover remains bound to the native exact checkpoint. + This function deliberately has no low-level fallback or automatic retry. + """ + if not isinstance(db, SessionDB) or db.read_only: + raise ClaimRefusal("WRITABLE_OWNER_REQUIRED") + try: + reader = BoundFileReader() + request = strict_json(reader.verified(str(request_path), expected_request_digest)) + exact_keys(request, ("checkpoint", "files", "session_id", "lease_holder")) + # The live dispatcher supplies both bindings from its selected agent, + # never from RPC params. Other private callers retain native-only checks. + if expected_session_id is not None or expected_lease_holder is not None: + if (type(expected_session_id) is not str or not expected_session_id + or type(expected_lease_holder) is not str or not expected_lease_holder): + raise ClaimRefusal("INVALID_LIVE_BINDING") + if request["session_id"] != expected_session_id: + raise ClaimRefusal("SESSION_MISMATCH") + if request["lease_holder"] != expected_lease_holder: + raise ClaimRefusal("LEASE_MISMATCH") + checkpoint = RunCheckpoint.from_dict(request["checkpoint"]) + source_count = verify_checkpoint_files(checkpoint, request["files"], reader) + except (ResumeRefusal, RunCustodyError) as exc: + raise ClaimRefusal(str(exc)) from None + except (TypeError, ValueError, KeyError, RecursionError): + raise ClaimRefusal("INVALID_REQUEST") from None + try: + value = db.claim_run_custody_checked(run_id, + expected_generation=expected_generation, checkpoint=checkpoint, + origin_session_id=origin_session_id, current_session_id=request["session_id"], + lease_holder=request["lease_holder"], historical_goal_digest=historical_goal_digest, + controller_pid=controller_pid, ttl_seconds=ttl_seconds) + except RunCustodyError as exc: + raise ClaimRefusal(exc.code) from None + except BaseException: # Cancellation can follow a committed native mutation. + # Even a transport-looking error can be a lost ACK after commit. + raise ClaimOutcomeUnknown("CLAIM_OUTCOME_UNKNOWN") from None + try: + # Private executing-owner hook: retain the native handle before readback. + # It never crosses the RPC boundary and does not authorize other effects. + if _on_claim is not None: + _on_claim(value) + current = db.read_run_custody(run_id) + except BaseException: # Cancellation can follow a committed native mutation. + raise ClaimOutcomeUnknown("CLAIM_READBACK_UNKNOWN") from None + if current != value: + raise ClaimOutcomeUnknown("CLAIM_READBACK_MISMATCH") + return {"status": "claim_observed", "run_id": value.run_id, + "generation": value.generation, "custody_changed": True, + "resume_authorized": False, "downstream_effects_executed": False, + "source_files": source_count, "members": len(checkpoint.members), + "unresolved_effects": len(checkpoint.unresolved_effects), + "unresolved_findings": len(checkpoint.unresolved_findings), + "restrictions": len(checkpoint.restrictions)} diff --git a/scripts/run_checkpoint_resume.py b/scripts/run_checkpoint_resume.py new file mode 100644 index 000000000000..b4094396e2ff --- /dev/null +++ b/scripts/run_checkpoint_resume.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Private read-only SessionDB resume observer, not an effect-admission API. + +Run with the source checkout's supported Python and explicit --db/--run-id/ +--generation/--request. The request has checkpoint (native RunCheckpoint JSON), +session_id, lease_holder, and files {plan, contract, source, members {name:path}}. +The source file is a nonempty JSON {absolute_path: sha256} inventory; its exact +bytes must match the independently read native source_digest. Every native +member has an exact UTF-8 file binding. Member historical-goal-key names the +native metadata whose raw digest is historical_goal_digest. + +Observations are bounded, point-in-time reads, NOT an atomic filesystem/DB +snapshot, current effect authority, or whole runtime/compaction qualification. +There is deliberately no claim, refresh, publish, replay, or effect command. +""" +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import re +import sqlite3 +import stat +import sys +import time + +# This private script belongs to this checkout, not an ambient installed copy. +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint, RunCustodyError + +MAX_FILE_BYTES = 8_000_000 +MAX_TOTAL_BYTES = 64_000_000 +MAX_SOURCE_FILES = 1000 + + +class ResumeRefusal(RuntimeError): + """Stable codes only: never include raw state, paths, or bearer values.""" + + +def read_file_bytes(path): + if type(path) is not str or not Path(path).is_absolute(): + raise ResumeRefusal("INVALID_FILE") + target = Path(path) + try: + # No claim of hostile parent-directory race resistance. Final symlink + # and special-file checks prevent accidental redirection/blocking. + if target.is_symlink(): + raise ResumeRefusal("INVALID_FILE") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + fd = os.open(path, flags) + with os.fdopen(fd, "rb") as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_FILE_BYTES: + raise ResumeRefusal("INVALID_FILE") + raw = stream.read(MAX_FILE_BYTES + 1) + after = os.fstat(stream.fileno()) + if len(raw) > MAX_FILE_BYTES: + raise ResumeRefusal("INVALID_FILE") + if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != ( + after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns + ): + raise ResumeRefusal("FILE_CHANGED_DURING_READ") + return raw + except OSError as exc: + raise ResumeRefusal("INVALID_FILE") from exc + + +def strict_json(raw): + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ResumeRefusal("DUPLICATE_KEY") + result[key] = value + return result + def bad_constant(_value): + raise ResumeRefusal("INVALID_JSON") + try: + return json.loads(raw, object_pairs_hook=pairs, parse_constant=bad_constant) + except (ValueError, UnicodeError, RecursionError) as exc: + raise ResumeRefusal("INVALID_JSON") from exc + + +def exact_keys(value, keys): + if type(value) is not dict or set(value) != set(keys): + raise ResumeRefusal("INVALID_REQUEST") + + +def check_lease(db, owner, request): + sid = request["session_id"] + if type(sid) is not str or sid != owner.current_session_id: + raise ResumeRefusal("SESSION_MISMATCH") + holder = request["lease_holder"] + if type(holder) is not str or not holder: + raise ResumeRefusal("LEASE_MISMATCH") + # Existing SessionDB lineage resolver, in the same read context as the + # lease observation. This does not acquire/refresh or grant run custody. + with db._read_ctx() as conn: + session = conn.execute("SELECT ended_at,end_reason FROM sessions WHERE id=?", (sid,)).fetchone() + if session is None: + raise ResumeRefusal("SESSION_MISMATCH") + if session["ended_at"] is not None or session["end_reason"] is not None: + raise ResumeRefusal("SESSION_NOT_CURRENT") + root = db._session_turn_lease_key_on_conn(conn, sid) + row = conn.execute("SELECT holder,expires_at FROM session_turn_leases " + "WHERE conversation_id=?", (root,)).fetchone() + if row is None or row["holder"] != holder: + raise ResumeRefusal("LEASE_MISMATCH") + expiry = float(row["expires_at"]) + if not math.isfinite(expiry) or expiry <= time.time(): + raise ResumeRefusal("LEASE_MISMATCH") + + +def check_goal(db, owner): + key = dict(owner.checkpoint.members).get("historical-goal-key") + if not key: + raise ResumeRefusal("HISTORICAL_GOAL_BINDING_MISSING") + raw = db.get_meta(key) + if raw is None or hashlib.sha256(raw.encode("utf-8")).hexdigest() != owner.historical_goal_digest: + raise ResumeRefusal("HISTORICAL_GOAL_MISMATCH") + + +class BoundFileReader: + """One bounded observation budget; no authority or atomic snapshot.""" + + def __init__(self): + self.total = 0 + + def read(self, path): + raw = read_file_bytes(path) + self.total += len(raw) + if self.total > MAX_TOTAL_BYTES: + raise ResumeRefusal("READ_BUDGET_EXCEEDED") + return raw + + def verified(self, path, digest): + if type(digest) is not str or re.fullmatch(r"[0-9a-f]{64}", digest) is None: + raise ResumeRefusal("INVALID_DIGEST") + raw = self.read(path) + if hashlib.sha256(raw).hexdigest() != digest: + raise ResumeRefusal("FILE_DIGEST_MISMATCH") + return raw + + +def verify_checkpoint_files(checkpoint, files, reader): + """Compare caller-selected files with explicit checkpoint bindings.""" + exact_keys(files, ("plan", "contract", "source", "members")) + reader.verified(files["plan"], checkpoint.plan_digest) + reader.verified(files["contract"], checkpoint.contract_digest) + inventory = strict_json(reader.verified(files["source"], checkpoint.source_digest)) + if type(inventory) is not dict or not 1 <= len(inventory) <= MAX_SOURCE_FILES: + raise ResumeRefusal("INVALID_SOURCE_INVENTORY") + for source, digest in inventory.items(): + reader.verified(source, digest) + members = dict(checkpoint.members) + if type(files["members"]) is not dict or set(files["members"]) != set(members): + raise ResumeRefusal("MEMBER_BINDINGS_MISMATCH") + for name, raw in members.items(): + if reader.read(files["members"][name]) != raw.encode("utf-8"): + raise ResumeRefusal("MEMBER_MISMATCH") + return len(inventory) + + +def inspect_resume(db_path, run_id, generation, request_path): + path = Path(db_path) + if not path.is_absolute() or path.is_symlink() or not path.is_file(): + raise ResumeRefusal("INVALID_DB") + reader = BoundFileReader() + request = strict_json(reader.read(str(request_path))) + exact_keys(request, ("checkpoint", "files", "session_id", "lease_holder")) + files = request["files"] + exact_keys(files, ("plan", "contract", "source", "members")) + db = None + try: + db = SessionDB(db_path=path, read_only=True) + owner = db.read_run_custody(run_id) + if owner is None or owner.generation != generation or type(generation) is not int: + raise ResumeRefusal("FENCE_MISMATCH") + checkpoint = RunCheckpoint.from_dict(request["checkpoint"]) + if checkpoint != owner.checkpoint: + raise ResumeRefusal("CHECKPOINT_MISMATCH") + def native_check(): + value = db.validate_run_resume(run_id, owner_token=owner.owner_token, + expected_generation=generation, source_digest=checkpoint.source_digest, + plan_digest=checkpoint.plan_digest, contract_digest=checkpoint.contract_digest) + if value != owner: + raise ResumeRefusal("NATIVE_STATE_CHANGED") + check_lease(db, owner, request) + check_goal(db, owner) + native_check() + source_count = verify_checkpoint_files(checkpoint, files, reader) + native_check() + return {"status": "resume_consistency", "run_id": owner.run_id, + "generation": owner.generation, "resume_authorized": False, + "effects_executed": False, "source_files": source_count, + "members": len(checkpoint.members), "unresolved_effects": len(checkpoint.unresolved_effects), + "unresolved_findings": len(checkpoint.unresolved_findings), + "restrictions": len(checkpoint.restrictions)} + except RunCustodyError as exc: + raise ResumeRefusal(exc.code) from exc + except (sqlite3.Error, OSError) as exc: + raise ResumeRefusal("STORE_UNAVAILABLE") from exc + except (TypeError, ValueError, KeyError, RecursionError) as exc: + raise ResumeRefusal("INVALID_REQUEST") from exc + finally: + if db is not None: + db.close() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", required=True, type=Path) + parser.add_argument("--run-id", required=True) + parser.add_argument("--generation", required=True, type=int) + parser.add_argument("--request", required=True, type=Path) + args = parser.parse_args(argv) + try: + result = inspect_resume(args.db, args.run_id, args.generation, args.request) + except ResumeRefusal as exc: + print(json.dumps({"status": "refused", "code": str(exc), + "resume_authorized": False, "effects_executed": False})) + return 2 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json index 74fe2df378f9..5de9220a4958 100644 --- a/scripts/whatsapp-bridge/package-lock.json +++ b/scripts/whatsapp-bridge/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@whiskeysockets/baileys": "7.0.0-rc13", - "express": "^4.21.0", + "express": "4.22.2", "pino": "^9.0.0", "qrcode-terminal": "^0.12.0" } @@ -61,9 +61,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "peer": true, @@ -97,9 +97,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -116,13 +116,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -139,13 +139,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ @@ -153,7 +153,7 @@ ], "peer": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -163,9 +163,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -180,9 +180,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -197,12 +197,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -214,12 +217,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -231,12 +237,15 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -248,12 +257,15 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -265,12 +277,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -282,12 +297,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -299,12 +317,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -316,12 +337,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -333,12 +357,15 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -352,16 +379,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -375,16 +405,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -398,16 +431,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -421,16 +457,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -444,16 +483,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -467,16 +509,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -490,16 +535,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -513,18 +561,18 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "peer": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -534,9 +582,9 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], @@ -544,7 +592,7 @@ "optional": true, "peer": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -554,9 +602,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -574,9 +622,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -594,9 +642,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -1713,9 +1761,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -1867,9 +1915,9 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -1884,31 +1932,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" }, "peerDependenciesMeta": { "@types/node": { diff --git a/scripts/whatsapp-bridge/package.json b/scripts/whatsapp-bridge/package.json index c6c3a5251827..a2ada4fe8f06 100644 --- a/scripts/whatsapp-bridge/package.json +++ b/scripts/whatsapp-bridge/package.json @@ -9,12 +9,14 @@ }, "dependencies": { "@whiskeysockets/baileys": "7.0.0-rc13", - "express": "^4.21.0", + "express": "4.22.2", "qrcode-terminal": "^0.12.0", "pino": "^9.0.0" }, "overrides": { "protobufjs": "^7.5.5", - "body-parser": "1.20.6" + "body-parser": "1.20.6", + "qs": "6.16.0", + "sharp": "0.35.4" } } diff --git a/tests-js/package.json b/tests-js/package.json index 06d833ebb515..582616dfb694 100644 --- a/tests-js/package.json +++ b/tests-js/package.json @@ -14,6 +14,6 @@ "@types/plist": "3.0.5", "plist": "3.1.1", "typescript": "6.0.3", - "vitest": "4.1.10" + "vitest": "4.1.11" } } diff --git a/tests/run_agent/test_cross_process_turn_lease.py b/tests/run_agent/test_cross_process_turn_lease.py index a248dc319016..e918acd0942f 100644 --- a/tests/run_agent/test_cross_process_turn_lease.py +++ b/tests/run_agent/test_cross_process_turn_lease.py @@ -66,6 +66,114 @@ def _agent_with_db(db, *, session_id="stale-parent", platform="desktop"): return agent +def test_real_turn_finally_releases_custody_before_session_lease(tmp_path, monkeypatch): + import dataclasses + import hashlib + import json + import pytest + from hermes_state_runs import RunCheckpoint + + def digest(data): + return hashlib.sha256(data).hexdigest() + + db = SessionDB(tmp_path / "state.db") + db.create_session("checkpoint-current", source="desktop") + goal = '{"status":"cleared","outcome":"CANCELLED"}' + db.set_meta("goal:old", goal) + source = tmp_path / "source" + source.write_bytes(b"source") + files = {"members": {}} + payloads = {"plan": b"plan", "contract": b"contract", "source": json.dumps({str(source): digest(b"source")}).encode()} + for name, data in payloads.items(): + path = tmp_path / (name + "-bound") + path.write_bytes(data) + files[name] = str(path) + member = tmp_path / "goal-key" + member.write_text("goal:old") + files["members"]["historical-goal-key"] = str(member) + cp = RunCheckpoint(digest(b"plan"), digest(b"contract"), digest(payloads["source"]), + "observe", (("historical-goal-key", "goal:old"),), (), (), ("no effects",)) + released = [] + original_release = db.release_session_turn_lease + + def ordered_release(session_id, holder): + assert db.read_run_custody("wrapper-run").disposition == "released" + released.append(1) + return original_release(session_id, holder) + + monkeypatch.setattr(db, "release_session_turn_lease", ordered_release) + agent = _agent_with_db(db, session_id="checkpoint-current") + generation = 0 + try: + for mode in ("normal", "error", "finalize"): + claimed, finish, cancelled = threading.Event(), threading.Event(), threading.Event() + def loop(current, *args, **kwargs): + request = tmp_path / "request.json" + request.write_text(json.dumps({"checkpoint": dataclasses.asdict(cp), "files": files, + "session_id": current.session_id, "lease_holder": current._active_session_turn_lease_holder})) + current._run_checkpoint_custody.claim(current._active_session_turn_lease_holder, + session_id=current.session_id, run_id="wrapper-run", expected_generation=generation, + request_path=str(request), expected_request_digest=digest(request.read_bytes()), + origin_session_id="old", historical_goal_digest=digest(goal.encode()), ttl_seconds=120) + if mode == "finalize": + claimed.set() + assert finish.wait(10) + if mode == "error": + raise RuntimeError("injected model-loop failure") + return {"final_response": "ok", "messages": [], "failed": False} + monkeypatch.setattr("agent.conversation_loop.run_conversation", loop) + if mode == "finalize": + from contextlib import nullcontext + from tui_gateway import server + failures = [] + def run(): + try: + AIAgent.run_conversation(agent, "test", conversation_history=[]) + except BaseException as exc: + failures.append(exc) + worker = threading.Thread(target=run) + worker.start() + try: + assert claimed.wait(10) + session = {"agent": agent, "running": True, "_run_thread": worker, + "history_lock": threading.Lock(), "session_key": agent.session_id, + "history": [], "source": "desktop"} + with monkeypatch.context() as patcher: + patcher.setattr(server, "_sessions", {"runtime": session}) + patcher.setattr(server, "_session_db", lambda s: nullcontext(db)) + patcher.setattr(agent, "hard_interrupt", lambda *a, **k: cancelled.set()) + server._finalize_session(session) + assert cancelled.is_set(), "finalization must request turn cancellation" + assert not session.get("_finalized") + assert session.get("_run_checkpoint_finalize_deferred") + assert db.read_run_custody("wrapper-run").disposition == "active" + assert db._conn.execute("SELECT count(*) FROM session_turn_leases").fetchone()[0] == 1 + finally: + finish.set() + worker.join(timeout=10) + assert not worker.is_alive() + assert not failures + with monkeypatch.context() as patcher: + patcher.setattr(server, "_session_db", lambda s: nullcontext(db)) + session["running"] = False + server._finalize_session(session) + assert session["_finalized"] is True + assert "_run_checkpoint_finalize_deferred" not in session + elif mode == "error": + with pytest.raises(RuntimeError, match="injected model-loop failure"): + AIAgent.run_conversation(agent, "test", conversation_history=[]) + else: + assert AIAgent.run_conversation(agent, "test", conversation_history=[])["final_response"] == "ok" + current = db.read_run_custody("wrapper-run") + assert current.disposition == "released" + generation = current.generation + assert agent._active_session_turn_lease_holder is None + assert db.get_meta("goal:old") == goal + assert len(released) == 3 + finally: + db.close() + + def test_run_conversation_acquires_then_reloads_latest_tip(monkeypatch): db = _DB() agent = _agent_with_db(db) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 9ede3265bdb7..26eefd25b730 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -4,6 +4,7 @@ import time import json import threading +from contextlib import contextmanager from pathlib import Path from unittest import mock @@ -813,45 +814,112 @@ def test_search_fields_project_results_without_changing_default(self, db): ] assert all("context" in row and row["context"] for row in default) - def test_search_projection_skips_context_enrichment_queries(self, db): + @pytest.mark.parametrize("read_path", ["journal-default", "writer-fallback"]) + def test_search_projection_skips_context_enrichment_queries( + self, db, monkeypatch, read_path + ): db.create_session(session_id="s1", source="cli") db.append_message("s1", role="user", content="before") db.append_message("s1", role="assistant", content="projectionneedle") db.append_message("s1", role="user", content="after") + if read_path == "writer-fallback": + # Exercise the real locked-writer path without faking query results. + monkeypatch.setattr(db, "_checkout_read_conn", lambda: None) + statements = [] - read_conn = db._get_read_conn() or db._conn - traced_connections = [db._conn] - if read_conn is not db._conn: - traced_connections.append(read_conn) - for conn in traced_connections: - conn.set_trace_callback(statements.append) + borrowed_connections = [] + original_read_ctx = db._read_ctx + + @contextmanager + def traced_read_ctx(): + # A direct _get_read_conn() opens an unpooled handle that search + # never uses. Trace only the actual borrow, before it is returned. + with original_read_ctx() as conn: + borrowed_connections.append(conn) + conn.set_trace_callback(statements.append) + try: + yield conn + finally: + conn.set_trace_callback(None) + + monkeypatch.setattr(db, "_read_ctx", traced_read_ctx) def context_query_count(): normalized = (" ".join(sql.upper().split()) for sql in statements) return sum("WITH TARGET AS (" in sql for sql in normalized) - try: - projected = db.search_messages( - "projectionneedle", fields=("session_id", "snippet") - ) - assert len(projected) == 1 - assert context_query_count() == 0 + # Positive control: absent tracing must not look like zero query work. + with db._read_ctx() as conn: + assert conn.execute( + "WITH target AS (SELECT 1) SELECT * FROM target" + ).fetchone()[0] == 1 + assert context_query_count() == 1 + statements.clear() - full = db.search_messages( - "projectionneedle", fields=("session_id", "context") + projected = db.search_messages( + "projectionneedle", fields=("session_id", "snippet") + ) + assert len(projected) == 1 + assert context_query_count() == 0 + assert statements # The projection did execute real search SQL. + + expected_context = [ + {"role": "user", "content": "before"}, + {"role": "assistant", "content": "projectionneedle"}, + {"role": "user", "content": "after"}, + ] + full = db.search_messages( + "projectionneedle", fields=("session_id", "context") + ) + assert len(full) == 1 + assert full[0]["context"] == expected_context + assert context_query_count() == 1 + + default = db.search_messages("projectionneedle") + assert len(default) == 1 + assert default[0]["context"] == expected_context + assert context_query_count() == 2 + + # Both normal and exceptional exits must detach the callback. Borrow + # through the original owner to check cleanup without reinstalling it. + before_cleanup_probe = list(statements) + with original_read_ctx() as conn: + conn.execute("SELECT 1").fetchone() + assert statements == before_cleanup_probe + with pytest.raises(RuntimeError, match="observer cleanup"): + with db._read_ctx(): + raise RuntimeError("observer cleanup") + with original_read_ctx() as conn: + conn.execute("SELECT 1").fetchone() + assert statements == before_cleanup_probe + + assert borrowed_connections + assert all(conn is borrowed_connections[0] for conn in borrowed_connections) + # The default path is pooled only when the runtime admits WAL. + # DELETE journal mode deliberately uses the locked writer instead. + journal_mode = db._conn.execute("PRAGMA journal_mode").fetchone()[0] + assert db._wal_active == (journal_mode == "wal") + if read_path == "journal-default" and db._wal_active: + assert borrowed_connections[0] is not db._conn + else: + assert borrowed_connections[0] is db._conn + + def test_search_projection_with_wal_safety_fallback(self, tmp_path, monkeypatch): + # Select the existing restrictive policy before opening a real DB. + # Never force WAL on a host whose SQLite safety gate rejects it. + monkeypatch.setattr( + hermes_state, "is_sqlite_wal_reset_vulnerable", lambda version_info=None: True + ) + db = SessionDB(db_path=tmp_path / "wal_safety_fallback.db") + try: + assert not db._wal_active + assert db._conn.execute("PRAGMA journal_mode").fetchone()[0] == "delete" + self.test_search_projection_skips_context_enrichment_queries( + db, monkeypatch, "journal-default" ) - assert len(full) == 1 - assert full[0]["context"] - assert context_query_count() == 1 - - default = db.search_messages("projectionneedle") - assert len(default) == 1 - assert default[0]["context"] - assert context_query_count() == 2 finally: - for conn in traced_connections: - conn.set_trace_callback(None) + db.close() def test_sanitize_fts5_query_strips_dangerous_chars(self): """Unit test for _sanitize_fts5_query static method.""" diff --git a/tests/test_run_checkpoint_claim_admission.py b/tests/test_run_checkpoint_claim_admission.py new file mode 100644 index 000000000000..077051c1622c --- /dev/null +++ b/tests/test_run_checkpoint_claim_admission.py @@ -0,0 +1,207 @@ +"""Checked-claim admission against real SessionDB state; no live adoption.""" +import dataclasses +import hashlib +import sqlite3 +import subprocess +import sys + +import pytest + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint, RunCustodyError + + +def sha(raw): + return hashlib.sha256(raw.encode()).hexdigest() + + +@pytest.fixture +def bound(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="current", source="cli") + assert db.try_acquire_session_turn_lease("current", "holder", ttl_seconds=120) + goal = '{"status":"cleared","outcome":"CANCELLED"}' + db.set_meta("goal:historical", goal) + checkpoint = RunCheckpoint( + plan_digest=sha("plan"), contract_digest=sha("contract"), source_digest=sha("source"), + next_action="Observe only; do not retry ambiguous effect", + members=(("historical-goal-key", "goal:historical"), ("original-wip", "retained")), + unresolved_effects=("push:unknown",), unresolved_findings=("open-finding",), + restrictions=("no implicit effects",)) + kwargs = dict(expected_generation=0, checkpoint=checkpoint, origin_session_id="historical", + current_session_id="current", historical_goal_digest=sha(goal), ttl_seconds=120) + yield db, kwargs, goal + db.close() + + +def checked(db, kwargs, holder="holder"): + method = getattr(db, "claim_run_custody_checked", None) + assert method is not None, "native checked-claim entrypoint missing" + return method("claim-fixture", lease_holder=holder, **kwargs) + + +def run_rows(db): + # Independent SQL readback, not the candidate's success return. + with sqlite3.connect(f"file:{db.db_path}?mode=ro", uri=True) as conn: + return conn.execute("SELECT key,value FROM state_meta WHERE key LIKE 'run-custody:%' ORDER BY key").fetchall() + + +def test_checked_claim_records_exact_checkpoint_without_goal_mutation(bound): + db, kwargs, goal = bound + value = checked(db, kwargs) + assert value.generation == 1 + assert value.checkpoint == kwargs["checkpoint"] + assert len(run_rows(db)) == 2 + assert db.read_run_custody("claim-fixture") == value + assert db.get_meta("goal:historical") == goal + + +@pytest.mark.parametrize("holder", [None, "", " ", 1, False]) +def test_checked_claim_never_falls_back_on_invalid_binding(bound, holder): + db, kwargs, goal = bound + with pytest.raises(RunCustodyError, match="INVALID_TEXT"): + checked(db, kwargs, holder) + assert run_rows(db) == [] + assert db.get_meta("goal:historical") == goal + + +@pytest.mark.parametrize("fault,code", [ + ("missing-session", "SESSION_MISMATCH"), + ("ended-session", "SESSION_NOT_CURRENT"), + ("compression-session", "SESSION_NOT_CURRENT"), + ("missing-lease", "LEASE_MISMATCH"), + ("wrong-lease", "LEASE_MISMATCH"), + ("expired-lease", "LEASE_MISMATCH"), + ("infinite-lease", "LEASE_MISMATCH"), + ("missing-goal", "HISTORICAL_GOAL_MISMATCH"), + ("changed-goal", "HISTORICAL_GOAL_MISMATCH"), + ("missing-key", "HISTORICAL_GOAL_BINDING_MISSING"), + ("wrong-key", "HISTORICAL_GOAL_MISMATCH"), +]) +def test_checked_claim_refuses_invalid_native_bindings(bound, fault, code): + db, kwargs, goal = bound + if fault == "missing-session": + kwargs["current_session_id"] = "absent" + elif fault == "ended-session": + db.end_session("current", "user_exit") + elif fault == "compression-session": + db.end_session("current", "compression") + elif fault == "missing-lease": + db.release_session_turn_lease("current", "holder") + elif fault in {"wrong-lease", "expired-lease", "infinite-lease"}: + sql, values = { + "wrong-lease": ("UPDATE session_turn_leases SET holder=?", ("other",)), + "expired-lease": ("UPDATE session_turn_leases SET expires_at=?", (0,)), + "infinite-lease": ("UPDATE session_turn_leases SET expires_at=?", (float("inf"),)), + }[fault] + db._execute_write(lambda conn: conn.execute(sql, values)) + elif fault == "missing-goal": + db._execute_write(lambda conn: conn.execute("DELETE FROM state_meta WHERE key='goal:historical'")) + elif fault == "changed-goal": + db.set_meta("goal:historical", "changed") + else: + members = () if fault == "missing-key" else (("historical-goal-key", "goal:other"),) + kwargs["checkpoint"] = dataclasses.replace(kwargs["checkpoint"], members=members) + before = db.get_meta("goal:historical") + with pytest.raises(RunCustodyError, match=code): + checked(db, kwargs) + assert run_rows(db) == [] + assert db.get_meta("goal:historical") == before + + +@pytest.mark.parametrize("fault,code", [ + ("session", "SESSION_NOT_CURRENT"), + ("lease", "LEASE_MISMATCH"), + ("goal", "HISTORICAL_GOAL_MISMATCH"), + ("control", None), +]) +def test_competing_process_changes_binding_before_write_admission(bound, monkeypatch, fault, code): + db, kwargs, goal = bound + child_code = r''' +import json,sqlite3,sys +conn=sqlite3.connect(sys.argv[1],timeout=10,isolation_level=None) +conn.execute("BEGIN IMMEDIATE") +changes={ + "session": ("UPDATE sessions SET end_reason='compression' WHERE id='current'",()), + "lease": ("UPDATE session_turn_leases SET holder='other'",()), + "goal": ("UPDATE state_meta SET value='changed' WHERE key='goal:historical'",()), + "control": ("SELECT 1",()), +} +conn.execute(*changes[sys.argv[2]]) +print("holding",flush=True) +if sys.stdin.readline().strip() != "commit": raise SystemExit(3) +conn.commit(); conn.close(); print("committed",flush=True) +''' + child = subprocess.Popen([sys.executable, "-u", "-c", child_code, str(db.db_path), fault], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + assert child.stdout is not None + from concurrent.futures import ThreadPoolExecutor + pool = ThreadPoolExecutor(max_workers=1) + real_write = db._execute_write + admitted = False + try: + assert pool.submit(child.stdout.readline).result(timeout=10).strip() == "holding" + def barrier(fn, *args, **kwargs): + nonlocal admitted + admitted = True + # The claim has already pre-read its head. A real separate process + # commits the changed binding before the native write admission. + out, err = child.communicate("commit\n", timeout=10) + assert child.returncode == 0, err + assert out.strip() == "committed" + return real_write(fn, *args, **kwargs) + monkeypatch.setattr(db, "_execute_write", barrier) + if code is None: + assert checked(db, kwargs).checkpoint == kwargs["checkpoint"] + assert len(run_rows(db)) == 2 + else: + with pytest.raises(RunCustodyError, match=code): + checked(db, kwargs) + assert run_rows(db) == [] + assert admitted + assert db.get_meta("goal:historical") == ("changed" if fault == "goal" else goal) + finally: + if child.poll() is None: + child.kill() + child.wait(timeout=10) + pool.shutdown(wait=True, cancel_futures=True) + + +def test_checked_claim_uses_existing_compression_lease_root(bound): + db, kwargs, goal = bound + db.end_session("current", "compression") + db.create_session(session_id="continued", source="cli", parent_session_id="current") + kwargs["current_session_id"] = "continued" + value = checked(db, kwargs) + assert value.current_session_id == "continued" + assert db.get_meta("goal:historical") == goal + + +def test_checked_claim_rollback_leaves_no_partial_generation(bound): + db, kwargs, goal = bound + db._conn.execute("CREATE TRIGGER reject_checked_head BEFORE INSERT ON state_meta " + "WHEN NEW.key LIKE 'run-custody:%:head' BEGIN SELECT RAISE(ABORT,'injected'); END") + with pytest.raises(sqlite3.IntegrityError, match="injected"): + checked(db, kwargs) + assert run_rows(db) == [] + assert db.get_meta("goal:historical") == goal + + +def test_checked_claim_retains_takeover_and_aba_fences(bound): + db, kwargs, goal = bound + first = checked(db, kwargs) + with pytest.raises(RunCustodyError, match="FENCE_MISMATCH"): + checked(db, kwargs) + kwargs["expected_generation"] = 1 + with pytest.raises(RunCustodyError, match="OWNER_ACTIVE"): + checked(db, kwargs) + released = db.release_run_custody("claim-fixture", owner_token=first.owner_token, expected_generation=1) + kwargs["expected_generation"] = released.generation + bad = dict(kwargs, checkpoint=dataclasses.replace(kwargs["checkpoint"], unresolved_effects=())) + with pytest.raises(RunCustodyError, match="TAKEOVER_REQUIRES_EXACT_CHECKPOINT"): + checked(db, bad) + current = checked(db, kwargs) + assert current.owner_token != first.owner_token + assert current.checkpoint == first.checkpoint + assert db.read_run_checkpoint("claim-fixture", generation=1) == first + assert db.get_meta("goal:historical") == goal diff --git a/tests/test_run_checkpoint_claim_client.py b/tests/test_run_checkpoint_claim_client.py new file mode 100644 index 000000000000..feb8d7ad8dc0 --- /dev/null +++ b/tests/test_run_checkpoint_claim_client.py @@ -0,0 +1,200 @@ +"""Private checked-claim client, exercised against real SessionDB fixtures.""" +import dataclasses +import hashlib +import importlib +import json +import os +import sqlite3 + +import pytest + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def client_module(): + try: + return importlib.import_module("scripts.run_checkpoint_claim") + except ModuleNotFoundError: + pytest.fail("private file-bound claim client missing") + + +@pytest.fixture +def bound(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="current", source="cli") + assert db.try_acquire_session_turn_lease("current", "holder", ttl_seconds=120) + goal = '{"status":"cleared","outcome":"CANCELLED"}' + db.set_meta("goal:old", goal) + source = tmp_path / "source.bin" + source.write_bytes(b"exact source") + inventory = json.dumps({str(source): sha(source.read_bytes())}).encode() + files = {} + for name, raw in {"plan": b"plan", "contract": b"contract", "source": inventory}.items(): + file = tmp_path / name + file.write_bytes(raw) + files[name] = str(file) + members = (("historical-goal-key", "goal:old"), ("authority", "Never retry unknown effects")) + files["members"] = {} + for name, raw in members: + file = tmp_path / (name + ".txt") + file.write_text(raw) + files["members"][name] = str(file) + cp = RunCheckpoint(sha(b"plan"), sha(b"contract"), sha(inventory), "Reconcile only", + members, ("push:unknown",), ("FTS:open",), ("no activation",)) + request = {"checkpoint": dataclasses.asdict(cp), "files": files, + "session_id": "current", "lease_holder": "holder"} + path = tmp_path / "request.json" + path.write_text(json.dumps(request)) + kwargs = dict(run_id="client-fixture", expected_generation=0, request_path=path, + expected_request_digest=sha(path.read_bytes()), origin_session_id="old", + historical_goal_digest=sha(goal.encode()), controller_pid=os.getpid(), ttl_seconds=120) + yield db, cp, request, kwargs, source, goal + db.close() + + +def rows(db): + with sqlite3.connect(f"file:{db.db_path}?mode=ro", uri=True) as conn: + return conn.execute("SELECT key,value FROM state_meta ORDER BY key").fetchall() + + +def call(bound): + db, _, _, kwargs, *_ = bound + return client_module().claim_from_files(db, **kwargs) + + +def rewrite_request(bound): + _, _, request, kwargs, *_ = bound + kwargs["request_path"].write_text(json.dumps(request)) + kwargs["expected_request_digest"] = sha(kwargs["request_path"].read_bytes()) + + +def test_client_claims_then_read_only_observer_sees_exact_native_state(bound): + db, cp, request, kwargs, source, goal = bound + result = call(bound) + assert result["status"] == "claim_observed" + assert result["generation"] == 1 + assert result["downstream_effects_executed"] is False + assert result["resume_authorized"] is False + owner = db.read_run_custody("client-fixture") + assert owner.checkpoint == cp + assert db.get_meta("goal:old") == goal + assert owner.owner_token not in json.dumps(result) + assert "holder" not in json.dumps(result) + observer = importlib.import_module("scripts.run_checkpoint_resume") + before = rows(db) + assert observer.inspect_resume(db.db_path, "client-fixture", 1, kwargs["request_path"])["status"] == "resume_consistency" + assert before == rows(db) + db.set_meta("handle-still-owned", "usable") + assert db.get_meta("handle-still-owned") == "usable" + + +@pytest.mark.parametrize("fault", ["plan", "contract", "source", "member", "inventory", "request", + "empty-inventory", "missing-member", "extra-member", "extra-key", + "duplicate-key", "invalid-digest", "symlink"]) +def test_file_refusal_happens_before_native_mutation(bound, fault): + module = client_module() + db, cp, request, kwargs, source, goal = bound + from pathlib import Path + if fault in {"plan", "contract", "source"}: + Path(request["files"][fault]).write_bytes(b"changed") + elif fault == "member": + Path(request["files"]["members"]["authority"]).write_text("retry now") + elif fault == "inventory": + source.write_bytes(b"changed") + elif fault == "request": + kwargs["request_path"].write_bytes(b"{}") + elif fault == "empty-inventory": + Path(request["files"]["source"]).write_bytes(b"{}") + request["checkpoint"]["source_digest"] = sha(b"{}") + rewrite_request(bound) + elif fault in {"missing-member", "extra-member"}: + if fault == "missing-member": + del request["files"]["members"]["authority"] + else: + request["files"]["members"]["extra"] = str(source) + rewrite_request(bound) + elif fault == "extra-key": + request["grant"] = True + rewrite_request(bound) + elif fault == "duplicate-key": + kwargs["request_path"].write_bytes(b'{"checkpoint":{},"checkpoint":{}}') + kwargs["expected_request_digest"] = sha(kwargs["request_path"].read_bytes()) + elif fault == "invalid-digest": + kwargs["expected_request_digest"] = "" + else: + link = source.with_name("source-link") + link.symlink_to(source) + request["files"]["plan"] = str(link) + rewrite_request(bound) + before = rows(db) + with pytest.raises(module.ClaimRefusal): + call(bound) + assert rows(db) == before + + +@pytest.mark.parametrize("fault", ["session", "lease", "goal", "goal-digest", "controller", "generation"]) +def test_client_uses_checked_native_admission_without_fallback(bound, fault): + module = client_module() + db, cp, request, kwargs, source, goal = bound + if fault == "session": + db.end_session("current", "compression") + elif fault == "lease": + db.release_session_turn_lease("current", "holder") + elif fault == "goal": + db.set_meta("goal:old", "changed") + elif fault == "goal-digest": + kwargs["historical_goal_digest"] = "bad" + elif fault == "controller": + kwargs["controller_pid"] = -1 + else: + kwargs["expected_generation"] = 2 + before = rows(db) + with pytest.raises(module.ClaimRefusal): + call(bound) + assert rows(db) == before + + +def test_read_only_handle_is_refused_and_not_closed(bound): + module = client_module() + db, cp, request, kwargs, source, goal = bound + with SessionDB(db_path=db.db_path, read_only=True) as ro: + with pytest.raises(module.ClaimRefusal, match="WRITABLE_OWNER_REQUIRED"): + module.claim_from_files(ro, **kwargs) + assert ro.get_meta("goal:old") == goal + assert db.read_run_custody("client-fixture") is None + + +def test_lost_ack_is_unknown_and_retry_does_not_duplicate(bound, monkeypatch): + module = client_module() + db, cp, request, kwargs, source, goal = bound + original = db.claim_run_custody_checked + def lost_ack(*args, **kw): + original(*args, **kw) + raise OSError("sensitive implementation detail") + monkeypatch.setattr(db, "claim_run_custody_checked", lost_ack) + with pytest.raises(module.ClaimOutcomeUnknown, match="CLAIM_OUTCOME_UNKNOWN"): + call(bound) + assert db.read_run_custody("client-fixture").generation == 1 + before = rows(db) + monkeypatch.setattr(db, "claim_run_custody_checked", original) + with pytest.raises(module.ClaimRefusal, match="FENCE_MISMATCH"): + call(bound) + assert rows(db) == before + assert db.get_meta("goal:old") == goal + + +def test_readback_failure_never_reports_refusal_or_retries(bound, monkeypatch): + module = client_module() + db, cp, request, kwargs, source, goal = bound + original = db.read_run_custody + monkeypatch.setattr(db, "read_run_custody", lambda *args: None) + with pytest.raises(module.ClaimOutcomeUnknown, match="CLAIM_READBACK_MISMATCH"): + call(bound) + monkeypatch.setattr(db, "read_run_custody", original) + assert db.read_run_custody("client-fixture").generation == 1 + assert db.get_meta("goal:old") == goal diff --git a/tests/test_run_checkpoint_import_boundaries.py b/tests/test_run_checkpoint_import_boundaries.py new file mode 100644 index 000000000000..c805f0340170 --- /dev/null +++ b/tests/test_run_checkpoint_import_boundaries.py @@ -0,0 +1,121 @@ +"""Wheel and psutil-absent import boundaries for run checkpoint support.""" +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig +import tomllib +import zipfile + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def isolated(code, source, cwd, *, without_psutil=False): + # Keep declared dependencies available, but do not execute site .pth files + # that could silently import an editable source checkout instead of the wheel. + prefix = "import sys; sys.path.insert(0, sys.argv[1]); sys.path.append(sys.argv[2]);\n" + if without_psutil: + prefix += ( + "import importlib.abc\n" + "class MissingPsutil(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path=None, target=None):\n" + " if fullname == 'psutil' or fullname.startswith('psutil.'):\n" + " raise ModuleNotFoundError('psutil intentionally unavailable')\n" + "sys.meta_path.insert(0, MissingPsutil())\n" + ) + return subprocess.run( + [sys.executable, "-I", "-S", "-c", prefix + code, str(source), sysconfig.get_path("purelib")], + cwd=cwd, capture_output=True, text=True, timeout=30, + ) + + +@pytest.fixture(scope="module") +def built_wheel(tmp_path_factory): + work = tmp_path_factory.mktemp("checkpoint-wheel") + source = work / "source" + source.mkdir() + config = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + for name in ("pyproject.toml", "README.md", "LICENSE"): + if (ROOT / name).is_file(): + shutil.copy2(ROOT / name, source / name) + # Preserve the real package discovery configuration and all its package roots. + packages = config["tool"]["setuptools"]["packages"]["find"]["include"] + for package in sorted({p.split(".")[0] for p in packages}): + if (ROOT / package).is_dir(): + shutil.copytree(ROOT / package, source / package, + ignore=shutil.ignore_patterns("__pycache__", "node_modules", ".venv")) + modules = set(config["tool"]["setuptools"]["py-modules"]) + # Include the actual source inputs even before the packaging allowlist is repaired. + modules.update(("scripts.run_checkpoint_claim", "scripts.run_checkpoint_resume")) + for module in modules: + relative = Path(*module.split(".")).with_suffix(".py") + (source / relative).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / relative, source / relative) + wheels = work / "wheels" + wheels.mkdir() + build = subprocess.run( + [sys.executable, "-c", "import setuptools.build_meta as b; b.build_wheel(__import__('sys').argv[1])", str(wheels)], + cwd=source, capture_output=True, text=True, timeout=120, + ) + assert build.returncode == 0, build.stdout + build.stderr + files = list(wheels.glob("*.whl")) + assert len(files) == 1 + installed = work / "installed" + with zipfile.ZipFile(files[0]) as wheel: + names = set(wheel.namelist()) + wheel.extractall(installed) + return work, installed, names + + +def test_wheel_contains_only_the_two_required_scripts_modules(built_wheel): + _, _, names = built_wheel + assert {p for p in names if p.startswith("scripts/")} == { + "scripts/run_checkpoint_claim.py", "scripts/run_checkpoint_resume.py", + } + + +def test_required_rpc_client_imports_from_extracted_wheel(built_wheel): + work, installed, _ = built_wheel + result = isolated( + "from pathlib import Path; import hermes_state_runs as owner; " + "from hermes_state import SessionDB; import scripts.run_checkpoint_claim as c; " + "import scripts.run_checkpoint_resume as r; " + "assert Path(owner.__file__).is_relative_to(Path(sys.argv[1])); " + "assert issubclass(SessionDB, owner.SessionRunCustodyMixin); " + "assert Path(c.__file__).is_relative_to(Path(sys.argv[1])); " + "assert Path(r.__file__).is_relative_to(Path(sys.argv[1])); " + "assert callable(c.claim_from_files); assert callable(r.verify_checkpoint_files); print('wheel-import-ok')", + installed, work, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "wheel-import-ok" + + +def test_state_scaffold_import_and_basic_store_without_psutil(tmp_path): + result = isolated( + "from pathlib import Path; import hermes_state; " + "assert hermes_state.psutil is None; " + "d=hermes_state.SessionDB(db_path=Path('state.db')); " + "d.set_meta('scaffold', 'usable'); assert d.get_meta('scaffold') == 'usable'; d.close(); print('scaffold-ok')", + ROOT, tmp_path, without_psutil=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "scaffold-ok" + + +@pytest.mark.parametrize("function", ["_process_identity", "_controller"]) +def test_custody_process_inspection_refuses_without_psutil(tmp_path, function): + code = ( + "import os; import hermes_state_runs as r\n" + "try:\n" + f" r.{function}(os.getpid())\n" + "except r.RunCustodyError as e:\n" + " assert e.code == 'PROCESS_INSPECTION_UNAVAILABLE'; print(e.code)\n" + "else:\n" + " raise AssertionError('missing dependency must not authorize or invent process identity')\n" + ) + result = isolated(code, ROOT, tmp_path, without_psutil=True) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "PROCESS_INSPECTION_UNAVAILABLE" diff --git a/tests/test_run_checkpoint_owner.py b/tests/test_run_checkpoint_owner.py new file mode 100644 index 000000000000..8f2a5c278b4c --- /dev/null +++ b/tests/test_run_checkpoint_owner.py @@ -0,0 +1,527 @@ +"""Run-scoped SessionDB owner witnesses; not a release/custody certificate.""" +import dataclasses +import hashlib +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from hermes_state import SessionDB + + +def digest(text): + return hashlib.sha256(text.encode()).hexdigest() + + +@pytest.fixture +def api(): + # Fail on an absent owner API rather than silently exercising a fake store. + assert hasattr(SessionDB, "claim_run_custody"), "native run custody API missing" + return importlib.import_module("hermes_state_runs") + + +@pytest.fixture +def db(tmp_path): + store = SessionDB(db_path=tmp_path / "state.db") + try: + yield store + finally: + store.close() + + +def checkpoint(api, **changes): + value = api.RunCheckpoint( + plan_digest=digest("plan"), contract_digest=digest("contract"), + source_digest=digest("source"), next_action="W01: reconcile source", + members=(("authority", "Do not revive the cancelled goal"),), + unresolved_effects=("push:unknown",), unresolved_findings=("F01",), + restrictions=("no force push",), + ) + return dataclasses.replace(value, **changes) + + +def claim(db, api): + return db.claim_run_custody( + "test-run", expected_generation=0, checkpoint=checkpoint(api), + origin_session_id="origin", current_session_id="current", + historical_goal_digest=digest("cancelled"), ttl_seconds=60, + ) + + +def test_owner_claim_preserves_goal_and_readback_after_lost_ack(db, api): + db.set_meta("goal:origin", '{"status":"cleared","outcome":"CANCELLED"}') + before = db.get_meta("goal:origin") + owner = claim(db, api) + assert owner.generation == 1 + assert owner.checkpoint == checkpoint(api) + assert db.read_run_custody("test-run") == owner + with pytest.raises(api.RunCustodyError, match="FENCE_MISMATCH"): + claim(db, api) + assert db.read_run_custody("test-run") == owner + assert db.get_meta("goal:origin") == before + + +def test_checkpoint_publish_requires_current_fence_and_owner(db, api): + owner = claim(db, api) + published = db.publish_run_checkpoint( + "test-run", owner_token=owner.owner_token, expected_generation=1, + expected_source_digest=owner.checkpoint.source_digest, + checkpoint=checkpoint(api, next_action="W02: inspect"), ttl_seconds=60, + ) + assert published.generation == 2 + for token, generation in [(owner.owner_token, 1), ("wrong", 2)]: + with pytest.raises(api.RunCustodyError): + db.publish_run_checkpoint( + "test-run", owner_token=token, expected_generation=generation, + expected_source_digest=owner.checkpoint.source_digest, + checkpoint=checkpoint(api), ttl_seconds=60, + ) + assert db.read_run_custody("test-run") == published + assert db.read_run_checkpoint("test-run", generation=1) == owner + + +@pytest.mark.parametrize("field,value", [ + ("unresolved_effects", ()), ("unresolved_findings", ()), + ("restrictions", ()), ("members", ()), +]) +def test_publish_cannot_drop_obligations_or_expected_members(db, api, field, value): + owner = claim(db, api) + with pytest.raises(api.RunCustodyError, match="OMITTED"): + db.publish_run_checkpoint( + "test-run", owner_token=owner.owner_token, expected_generation=1, + expected_source_digest=owner.checkpoint.source_digest, + checkpoint=checkpoint(api, **{field: value}), ttl_seconds=60, + ) + assert db.read_run_custody("test-run") == owner + + +@pytest.mark.parametrize("field,value,code", [ + ("source_digest", digest("substituted-source"), "SOURCE_MISMATCH"), + ("members", (("authority", "revive the cancelled goal"),), "SUBSTITUTED_MEMBER"), +]) +def test_publish_cannot_substitute_bound_source_or_member(db, api, field, value, code): + owner = claim(db, api) + with pytest.raises(api.RunCustodyError, match=code): + db.publish_run_checkpoint( + "test-run", owner_token=owner.owner_token, expected_generation=1, + expected_source_digest=owner.checkpoint.source_digest, + checkpoint=checkpoint(api, **{field: value}), ttl_seconds=60, + ) + assert db.read_run_custody("test-run") == owner + assert db.get_meta(api.generation_key("test-run", 2)) is None + + +def test_publish_can_append_members_without_replacing_bound_values(db, api): + owner = claim(db, api) + new = checkpoint(api, members=owner.checkpoint.members + (("observation-2", "new evidence"),)) + published = db.publish_run_checkpoint( + "test-run", owner_token=owner.owner_token, expected_generation=1, + expected_source_digest=owner.checkpoint.source_digest, checkpoint=new, ttl_seconds=60) + assert db.read_run_custody("test-run") == published + assert published.checkpoint.members == new.members + assert db.read_run_checkpoint("test-run", generation=1) == owner + + +def transition(db, owner, **changes): + assert hasattr(db, "transition_run_source"), "explicit source transition API missing" + kwargs = dict(owner_token=owner.owner_token, expected_generation=owner.generation, + expected_source_digest=owner.checkpoint.source_digest, + new_source_digest=digest("source-after-change"), + observation_ref="receipt:source-observation-2", observation_digest=digest("observation"), + ttl_seconds=60) + kwargs.update(changes) + return db.transition_run_source("test-run", **kwargs) + + +def test_explicit_source_transition_preserves_checkpoint_and_history(db, api): + owner = claim(db, api) + after = transition(db, owner) + assert after.generation == owner.generation + 1 + assert after.checkpoint.source_digest == digest("source-after-change") + assert after.checkpoint.members[:-1] == owner.checkpoint.members + name, raw = after.checkpoint.members[-1] + assert name == "source-transition:2" + assert json.loads(raw) == { + "schema": "RunSourceTransitionV1", "old_source_digest": owner.checkpoint.source_digest, + "new_source_digest": after.checkpoint.source_digest, + "observation_ref": "receipt:source-observation-2", "observation_digest": digest("observation")} + unchanged = dataclasses.replace(after.checkpoint, source_digest=owner.checkpoint.source_digest, + members=owner.checkpoint.members) + assert unchanged == owner.checkpoint + assert db.read_run_checkpoint("test-run", generation=1) == owner + assert db.read_run_custody("test-run") == after + with pytest.raises(api.RunCustodyError, match="SOURCE_MISMATCH"): + db.validate_run_resume("test-run", owner_token=after.owner_token, + expected_generation=2, source_digest=owner.checkpoint.source_digest, + plan_digest=owner.checkpoint.plan_digest, contract_digest=owner.checkpoint.contract_digest) + assert db.validate_run_resume("test-run", owner_token=after.owner_token, + expected_generation=2, source_digest=after.checkpoint.source_digest, + plan_digest=after.checkpoint.plan_digest, contract_digest=after.checkpoint.contract_digest) == after + + +@pytest.mark.parametrize("changes,code", [ + ({"expected_source_digest": digest("wrong")}, "SOURCE_MISMATCH"), + ({"expected_generation": 2}, "FENCE_MISMATCH"), + ({"owner_token": "wrong"}, "STALE_OWNER"), + ({"new_source_digest": digest("source")}, "SOURCE_UNCHANGED"), + ({"new_source_digest": None}, "INVALID_DIGEST"), + ({"observation_ref": " "}, "INVALID_TEXT"), + ({"observation_ref": None}, "INVALID_TEXT"), + ({"observation_ref": "x" * 1_000_001}, "INVALID_TEXT"), + ({"observation_digest": "invalid"}, "INVALID_DIGEST"), +]) +def test_source_transition_refusal_does_not_write(db, api, changes, code): + owner = claim(db, api) + with pytest.raises(api.RunCustodyError, match=code): + transition(db, owner, **changes) + assert db.read_run_custody("test-run") == owner + assert db.get_meta(api.generation_key("test-run", 2)) is None + + +def test_source_transition_released_owner_refuses(db, api): + owner = claim(db, api) + released = db.release_run_custody("test-run", owner_token=owner.owner_token, + expected_generation=1) + with pytest.raises(api.RunCustodyError, match="STALE_OWNER"): + transition(db, released) + assert db.read_run_custody("test-run") == released + + +def test_source_transition_expired_owner_refuses(db, api, monkeypatch): + owner = claim(db, api) + monkeypatch.setattr(api.time, "monotonic_ns", lambda: owner.expires_monotonic_ns) + with pytest.raises(api.RunCustodyError, match="OWNER_EXPIRED"): + transition(db, owner) + assert db.read_run_custody("test-run") == owner + + +def test_source_transition_head_failure_rolls_back_member(db, api): + import sqlite3 + owner = claim(db, api) + db._conn.execute("CREATE TRIGGER reject_transition BEFORE UPDATE ON state_meta " + "WHEN NEW.key LIKE 'run-custody:%:head' " + "BEGIN SELECT RAISE(ABORT, 'injected transition failure'); END") + with pytest.raises(sqlite3.IntegrityError): + transition(db, owner) + assert db.read_run_custody("test-run") == owner + assert db.get_meta(api.generation_key("test-run", 2)) is None + + +def test_source_transition_preserves_reserved_member_collision(db, api): + owner = db.claim_run_custody("test-run", expected_generation=0, + checkpoint=checkpoint(api, members=checkpoint(api).members + (("source-transition:2", "retained"),)), + origin_session_id="origin", current_session_id="current", + historical_goal_digest=digest("cancelled"), ttl_seconds=60) + with pytest.raises(api.RunCustodyError, match="DUPLICATE_MEMBER"): + transition(db, owner) + assert db.read_run_custody("test-run") == owner + + +def test_source_transition_expiry_during_admission_refuses(db, api, monkeypatch): + owner = claim(db, api) + real_write = db._execute_write + def delayed_write(fn, *args, **kwargs): + monkeypatch.setattr(api.time, "monotonic_ns", lambda: owner.expires_monotonic_ns + 1) + return real_write(fn, *args, **kwargs) + monkeypatch.setattr(db, "_execute_write", delayed_write) + with pytest.raises(api.RunCustodyError, match="OWNER_EXPIRED"): + transition(db, owner) + assert db.read_run_custody("test-run") == owner + + +def test_source_drift_refuses_publish_and_resume(db, api): + owner = claim(db, api) + with pytest.raises(api.RunCustodyError, match="SOURCE_MISMATCH"): + db.publish_run_checkpoint( + "test-run", owner_token=owner.owner_token, expected_generation=1, + expected_source_digest=digest("changed"), checkpoint=checkpoint(api), + ttl_seconds=60, + ) + with pytest.raises(api.RunCustodyError, match="SOURCE_MISMATCH"): + db.validate_run_resume("test-run", owner_token=owner.owner_token, + expected_generation=1, source_digest=digest("changed"), + plan_digest=digest("plan"), contract_digest=digest("contract")) + result = db.validate_run_resume("test-run", owner_token=owner.owner_token, + expected_generation=1, source_digest=digest("source"), + plan_digest=digest("plan"), contract_digest=digest("contract")) + assert result.checkpoint.unresolved_effects == ("push:unknown",) + # A validated read preserves an ambiguous effect, it never retries it. + assert db.read_run_custody("test-run").generation == 1 + + +def test_release_reclaim_aba_invalidates_token_and_preserves_history(db, api): + old = claim(db, api) + released = db.release_run_custody("test-run", owner_token=old.owner_token, + expected_generation=1) + assert released.generation == 2 and released.disposition == "released" + new = db.claim_run_custody("test-run", expected_generation=2, + checkpoint=released.checkpoint, origin_session_id="origin", + current_session_id="new-session", historical_goal_digest=digest("cancelled"), + ttl_seconds=60) + assert new.generation == 3 and new.owner_token != old.owner_token + for method in (db.refresh_run_custody, db.release_run_custody): + with pytest.raises(api.RunCustodyError, match="STALE_OWNER"): + method("test-run", owner_token=old.owner_token, expected_generation=3) + assert db.read_run_checkpoint("test-run", generation=1) == old + assert db.read_run_custody("test-run") == new + + +def test_active_owner_cannot_be_taken_over_even_by_same_process(db, api): + old = claim(db, api) + with pytest.raises(api.RunCustodyError, match="OWNER_ACTIVE"): + db.claim_run_custody("test-run", expected_generation=1, + checkpoint=old.checkpoint, origin_session_id="origin", + current_session_id="other", historical_goal_digest=digest("cancelled"), + ttl_seconds=60) + + +def test_member_substitution_cannot_replace_native_expected_digest(db, api): + old = claim(db, api) + # Same-UID raw SQL is outside the cooperating-writer fence. Readback must + # still detect member-only corruption against the separately stored head. + key = api.generation_key("test-run", 1) + raw = json.loads(db.get_meta(key)) + raw["checkpoint"]["members"][0][1] = "silently allow force push" + db.set_meta(key, json.dumps(raw, sort_keys=True, separators=(",", ":"))) + with pytest.raises(api.RunCustodyError, match="INTEGRITY"): + db.read_run_custody("test-run") + assert old.generation == 1 + + +def test_sql_failure_after_member_before_head_rolls_back_whole_generation(db, api): + old = claim(db, api) + db._conn.execute( + "CREATE TRIGGER reject_head BEFORE UPDATE ON state_meta " + "WHEN NEW.key LIKE 'run-custody:%:head' " + "BEGIN SELECT RAISE(ABORT, 'injected head failure'); END") + import sqlite3 + with pytest.raises(sqlite3.IntegrityError): + db.refresh_run_custody("test-run", owner_token=old.owner_token, + expected_generation=1) + assert db.get_meta(api.generation_key("test-run", 2)) is None + assert db.read_run_custody("test-run") == old + + +@pytest.mark.parametrize("field,value", [ + ("plan_digest", ""), ("source_digest", None), ("contract_digest", "x"), + ("next_action", ""), ("unresolved_effects", ("a", "a")), + ("members", (("x", "1"), ("x", "2"))), +]) +def test_malformed_checkpoint_is_refused_without_head(db, api, field, value): + with pytest.raises((api.RunCustodyError, ValueError, TypeError)): + db.claim_run_custody("test-run", expected_generation=0, + checkpoint=checkpoint(api, **{field: value}), origin_session_id="origin", + current_session_id="current", historical_goal_digest=digest("cancelled"), + ttl_seconds=60) + assert db.read_run_custody("test-run") is None + + +def test_expiry_while_waiting_for_write_lock_cannot_refresh(db, api, monkeypatch): + old = claim(db, api) + real_write = db._execute_write + + def delayed_write(fn, *args, **kwargs): + # Deterministic admission barrier: the original lease expires while + # the new generation waits, before any SQL mutation can commit. + monkeypatch.setattr(api.time, "monotonic_ns", lambda: old.expires_monotonic_ns + 1) + return real_write(fn, *args, **kwargs) + + monkeypatch.setattr(db, "_execute_write", delayed_write) + with pytest.raises(api.RunCustodyError, match="OWNER_EXPIRED"): + db.refresh_run_custody("test-run", owner_token=old.owner_token, + expected_generation=1, ttl_seconds=3600) + assert db.read_run_custody("test-run") == old + + +def test_expired_owner_refuses_refresh_but_can_explicitly_release(db, api, monkeypatch): + old = claim(db, api) + monkeypatch.setattr(api.time, "monotonic_ns", lambda: old.expires_monotonic_ns) + with pytest.raises(api.RunCustodyError, match="OWNER_EXPIRED"): + db.refresh_run_custody("test-run", owner_token=old.owner_token, expected_generation=1) + released = db.release_run_custody("test-run", owner_token=old.owner_token, expected_generation=1) + assert released.disposition == "released" + + +def test_process_start_identity_mismatch_refuses_current_token(db, api, monkeypatch): + old = claim(db, api) + real_identity = api._process_identity + monkeypatch.setattr(api, "_process_identity", + lambda pid: "different-start" if pid == old.controller_pid else real_identity(pid)) + with pytest.raises(api.RunCustodyError, match="STALE_PROCESS"): + db.refresh_run_custody("test-run", owner_token=old.owner_token, expected_generation=1) + assert db.read_run_custody("test-run") == old + + +_WORKER = r''' +import json, os, sys +from pathlib import Path +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint, RunCustodyError +from dataclasses import asdict +store = SessionDB(db_path=Path(sys.argv[1])) +mode = sys.argv[2] +checkpoint = RunCheckpoint.from_dict(json.loads(sys.argv[3])) +if mode == "crash-before-head": + store._conn.create_function("crash_now", 0, lambda: os._exit(81)) + store._conn.execute("CREATE TEMP TRIGGER crash_head BEFORE INSERT ON state_meta " + "WHEN NEW.key LIKE 'run-custody:%:head' BEGIN SELECT crash_now(); END") +print("ready", flush=True) +assert sys.stdin.readline().strip() == "go" +try: + if mode in {"publish", "transition"}: + owner = store.read_run_custody("test-run") + # Generation one is deliberately fixed before admission. Both workers + # were started only after the parent claimed it and before either go. + if mode == "publish": + value = store.publish_run_checkpoint("test-run", owner_token=owner.owner_token, + expected_generation=1, expected_source_digest=checkpoint.source_digest, + checkpoint=checkpoint, ttl_seconds=60) + else: + import hashlib + value = store.transition_run_source("test-run", owner_token=owner.owner_token, + expected_generation=1, expected_source_digest=checkpoint.source_digest, + new_source_digest=hashlib.sha256(str(os.getpid()).encode()).hexdigest(), + observation_ref="test:independent-observation", observation_digest=sys.argv[4], + ttl_seconds=60) + else: + value = store.claim_run_custody("test-run", expected_generation=0, + checkpoint=checkpoint, origin_session_id="origin", current_session_id="worker", + historical_goal_digest=sys.argv[4], ttl_seconds=60) + if mode == "crash-after-commit": + os._exit(82) + print(json.dumps({"result":"won", "value":asdict(value)}), flush=True) +except RunCustodyError as exc: + print(json.dumps({"result":exc.code}), flush=True) +store.close() +''' + + +def start_worker(tmp_path, api, mode): + import hermes_state + return subprocess.Popen([sys.executable, "-c", _WORKER, + str(tmp_path / "state.db"), mode, json.dumps(dataclasses.asdict(checkpoint(api))), + digest("cancelled")], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + env={**os.environ, "PYTHONPATH": str(Path(hermes_state.__file__).resolve().parent), + "HERMES_HOME": str(tmp_path / "worker-home")}) + + +def ready(worker): + # The pipe read has an independent deadline, even before the outer test + # runner deadline. Linux marker below admits this real pipe observer. + import select + assert select.select([worker.stdout], [], [], 15)[0], "worker readiness timeout" + assert worker.stdout.readline().strip() == "ready" + + +def cleanup(worker): + if worker.poll() is None: + worker.kill() + worker.communicate(timeout=10) + + +@pytest.mark.linux_only +def test_independent_process_claims_have_one_native_winner(tmp_path, api): + store = SessionDB(db_path=tmp_path / "state.db") + store.close() + workers = [] + try: + for _ in range(2): + worker = start_worker(tmp_path, api, "claim") + workers.append(worker) + ready(worker) + for worker in workers: + worker.stdin.write("go\n") + worker.stdin.flush() + outcomes = [] + for worker in workers: + out, err = worker.communicate(timeout=20) + assert worker.returncode == 0, err + outcomes.append(json.loads(out)) + assert sorted(item["result"] for item in outcomes) == ["FENCE_MISMATCH", "won"] + store = SessionDB(db_path=tmp_path / "state.db") + try: + winner = next(item["value"] for item in outcomes if item["result"] == "won") + observed = store.read_run_custody("test-run") + assert observed.owner_token == winner["owner_token"] + assert observed.generation == 1 + # Both contenders exited: takeover preserves the exact checkpoint, + # gets a fresh token, and invalidates the old token without ABA. + taken = store.claim_run_custody("test-run", expected_generation=1, + checkpoint=observed.checkpoint, origin_session_id="origin", + current_session_id="parent", historical_goal_digest=digest("cancelled")) + assert taken.generation == 2 and taken.owner_token != observed.owner_token + with pytest.raises(api.RunCustodyError, match="STALE_OWNER"): + store.refresh_run_custody("test-run", owner_token=observed.owner_token, + expected_generation=2) + finally: + store.close() + finally: + for worker in workers: + cleanup(worker) + + +@pytest.mark.linux_only +@pytest.mark.parametrize("mode", ["publish", "transition"]) +def test_independent_publishers_have_one_committed_generation(tmp_path, api, mode): + store = SessionDB(db_path=tmp_path / "state.db") + owner = claim(store, api) + workers = [] + try: + for _ in range(2): + worker = start_worker(tmp_path, api, mode) + workers.append(worker) + ready(worker) + for worker in workers: + worker.stdin.write("go\n") + worker.stdin.flush() + outcomes = [] + for worker in workers: + out, err = worker.communicate(timeout=20) + assert worker.returncode == 0, err + outcomes.append(json.loads(out)) + assert sorted(x["result"] for x in outcomes) == ["FENCE_MISMATCH", "won"] + winner = next(x["value"] for x in outcomes if x["result"] == "won") + observed = store.read_run_custody("test-run") + assert observed is not None + assert observed.generation == 2 + assert dataclasses.asdict(observed) == dataclasses.asdict(api.RunCustody.from_dict(winner)) + assert store.read_run_checkpoint("test-run", generation=1) == owner + assert store.get_meta(api.generation_key("test-run", 3)) is None + finally: + for worker in workers: + cleanup(worker) + store.close() + + +@pytest.mark.linux_only +@pytest.mark.parametrize("mode,exit_code,committed", [ + ("crash-before-head", 81, False), ("crash-after-commit", 82, True), +]) +def test_process_crash_exposes_only_old_or_complete_generation(tmp_path, api, mode, exit_code, committed): + store = SessionDB(db_path=tmp_path / "state.db") + store.set_meta("goal:origin", "cancelled goal bytes") + store.close() + worker = start_worker(tmp_path, api, mode) + try: + ready(worker) + out, err = worker.communicate("go\n", timeout=20) + assert worker.returncode == exit_code, (out, err) + store = SessionDB(db_path=tmp_path / "state.db") + try: + value = store.read_run_custody("test-run") + assert (value is not None) == committed + if committed: + assert value.checkpoint == checkpoint(api) + else: + assert store.get_meta(api.generation_key("test-run", 1)) is None + assert store.get_meta("goal:origin") == "cancelled goal bytes" + assert store._conn.execute("PRAGMA integrity_check").fetchone()[0] == "ok" + finally: + store.close() + finally: + cleanup(worker) diff --git a/tests/test_run_checkpoint_refresh_storage.py b/tests/test_run_checkpoint_refresh_storage.py new file mode 100644 index 000000000000..3f5d16a8f300 --- /dev/null +++ b/tests/test_run_checkpoint_refresh_storage.py @@ -0,0 +1,191 @@ +"""Immutable compact refreshes retain checkpoint history without payload copies.""" +from dataclasses import replace +import hashlib +import json + +import pytest + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint, RunCustodyError, generation_key + + +def digest(text): + return hashlib.sha256(text.encode()).hexdigest() + + +@pytest.fixture +def db(tmp_path): + store = SessionDB(db_path=tmp_path / "state.db") + yield store + store.close() + + +def claim(db): + cp = RunCheckpoint(digest("plan"), digest("contract"), digest("source"), "observe", + (("large-member", "x" * 200000),), ("effect:unknown",), + ("finding:open",), ("no effects",)) + return db.claim_run_custody("compact-test", expected_generation=0, checkpoint=cp, + origin_session_id="origin", current_session_id="current", + historical_goal_digest=digest("cancelled"), ttl_seconds=300) + + +def refresh(db, owner): + return db.refresh_run_custody(owner.run_id, owner_token=owner.owner_token, + expected_generation=owner.generation, ttl_seconds=300) + + +def logical_bytes(db): + return db._conn.execute("SELECT coalesce(sum(length(cast(value AS BLOB))),0) " + "FROM state_meta WHERE key LIKE 'run-custody:compact-test:%'").fetchone()[0] + + +def test_repeated_refresh_bounds_metadata_and_preserves_every_checkpoint(db): + owner = claim(db) + original = owner + initial = logical_bytes(db) + first_raw = db.get_meta(generation_key(owner.run_id, 1)) + history = [owner] + for _ in range(12): + owner = refresh(db, owner) + history.append(owner) + assert logical_bytes(db) - initial <= 12 * 8192 + assert db.get_meta(generation_key(owner.run_id, 1)) == first_raw + assert owner.checkpoint == original.checkpoint + assert owner.owner_token == original.owner_token + for value in history: + assert db.read_run_checkpoint(owner.run_id, generation=value.generation) == value + + +def test_refresh_after_publication_uses_new_checkpoint_and_retains_old(db): + first = claim(db) + second = refresh(db, first) + updated = replace(second.checkpoint, next_action="new checkpoint", + unresolved_findings=second.checkpoint.unresolved_findings + ("next",)) + third = db.publish_run_checkpoint(first.run_id, owner_token=second.owner_token, + expected_generation=second.generation, expected_source_digest=updated.source_digest, + checkpoint=updated) + initial = logical_bytes(db) + fourth = refresh(db, third) + assert logical_bytes(db) - initial <= 8192 + assert db.read_run_checkpoint(first.run_id, generation=1).checkpoint == first.checkpoint + assert fourth.checkpoint == updated + assert db.read_run_custody(first.run_id) == fourth + + +def republish_raw(db, generation, document): + raw = json.dumps(document, sort_keys=True, separators=(",", ":")) + db.set_meta(generation_key("compact-test", generation), raw) + db.set_meta("run-custody:compact-test:head", json.dumps({"generation": generation, "digest": digest(raw)})) + + +@pytest.mark.parametrize("fault", ["cycle", "wrong-digest", "ownership-change", "unknown-field"]) +def test_well_hashed_invalid_compact_record_is_refused(db, fault): + second = refresh(db, claim(db)) + record = json.loads(db.get_meta(generation_key(second.run_id, second.generation))) + assert record["schema"] == "SessionDBRunCustodyRefreshV1" + if fault == "cycle": + record["checkpoint_reference"]["generation"] = second.generation + elif fault == "wrong-digest": + record["checkpoint_reference"]["digest"] = "0" * 64 + elif fault == "ownership-change": + record["custody"]["current_session_id"] = "substituted" + else: + record["unrecognized"] = True + republish_raw(db, second.generation, record) + with pytest.raises(RunCustodyError, match="INTEGRITY"): + db.read_run_custody(second.run_id) + + +def test_checkpoint_reference_cannot_target_a_compact_predecessor(db): + second = refresh(db, claim(db)) + third = refresh(db, second) + second_raw = db.get_meta(generation_key(second.run_id, second.generation)) + record = json.loads(db.get_meta(generation_key(third.run_id, third.generation))) + record["checkpoint_reference"] = {"generation": second.generation, "digest": digest(second_raw)} + republish_raw(db, third.generation, record) + with pytest.raises(RunCustodyError, match="INTEGRITY_SCHEMA"): + db.read_run_custody(third.run_id) + + +def test_referenced_checkpoint_corruption_is_detected_after_many_refreshes(db): + owner = claim(db) + for _ in range(3): + owner = refresh(db, owner) + key = generation_key(owner.run_id, 1) + raw = json.loads(db.get_meta(key)) + raw["checkpoint"]["next_action"] = "tampered" + db.set_meta(key, json.dumps(raw)) + with pytest.raises(RunCustodyError, match="INTEGRITY"): + db.read_run_custody(owner.run_id) + + +def test_reference_is_rechecked_in_write_transaction_and_fault_rolls_back(db, monkeypatch): + first = claim(db) + key = generation_key(first.run_id, 1) + original = db.get_meta(key) + execute = db._execute_write + + def with_fault(fn, *args, **kwargs): + def fault(conn): + conn.execute("UPDATE state_meta SET value=? WHERE key=?", ("{}", key)) + return fn(conn) + return execute(fault, *args, **kwargs) + + monkeypatch.setattr(db, "_execute_write", with_fault) + with pytest.raises(RunCustodyError, match="INTEGRITY"): + refresh(db, first) + assert db.get_meta(key) == original + assert db.get_meta(generation_key(first.run_id, 2)) is None + assert db.read_run_custody(first.run_id) == first + + +def test_compact_refresh_reopens_without_rewriting_v1_or_compact_rows(db): + first = claim(db) + current = refresh(db, refresh(db, first)) + # Writable SessionDB reopen may initialize its independent FTS version key. + # This contract protects all native rows of this run, not unrelated owners. + before = db._conn.execute("SELECT key,value FROM state_meta WHERE key LIKE 'run-custody:compact-test:%' ORDER BY key").fetchall() + assert before + with SessionDB(db_path=db.db_path) as reopened: + assert reopened.read_run_custody(first.run_id) == current + assert reopened.read_run_checkpoint(first.run_id, generation=1) == first + assert db._conn.execute("SELECT key,value FROM state_meta WHERE key LIKE 'run-custody:compact-test:%' ORDER BY key").fetchall() == before + + +def test_concurrent_refresh_has_one_fenced_winner(db): + from concurrent.futures import ThreadPoolExecutor + import threading + + first = claim(db) + barrier = threading.Barrier(2) + + def compete(): + other = SessionDB(db_path=db.db_path) + try: + barrier.wait(timeout=10) + try: + return refresh(other, first).generation + except RunCustodyError as exc: + return exc.code + finally: + other.close() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: compete(), range(2))) + assert results.count(2) == 1 + assert results.count("FENCE_MISMATCH") == 1 + assert db.read_run_custody(first.run_id).generation == 2 + + +def test_compact_refresh_release_and_fenced_reclaim(db): + first = claim(db) + second = refresh(db, first) + with pytest.raises(RunCustodyError, match="FENCE_MISMATCH"): + refresh(db, first) + released = db.release_run_custody(second.run_id, owner_token=second.owner_token, + expected_generation=second.generation) + successor = db.claim_run_custody(second.run_id, expected_generation=released.generation, + checkpoint=released.checkpoint, origin_session_id=released.origin_session_id, + current_session_id="successor", historical_goal_digest=released.historical_goal_digest) + assert successor.owner_token != first.owner_token + assert db.read_run_checkpoint(first.run_id, generation=2) == second diff --git a/tests/test_run_checkpoint_resume.py b/tests/test_run_checkpoint_resume.py new file mode 100644 index 000000000000..67bf72f437bf --- /dev/null +++ b/tests/test_run_checkpoint_resume.py @@ -0,0 +1,264 @@ +"""Read-only consumer witnesses with real native storage and subprocesses.""" +import dataclasses +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys + +import pytest + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "run_checkpoint_resume.py" + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def test_consumer_entrypoint_available(): + assert SCRIPT.is_file(), "read-only native resume consumer missing" + + +@pytest.fixture +def client(): + spec = importlib.util.spec_from_file_location("run_checkpoint_resume", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def bound(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="current", source="cli") + holder = "fixture-controller" + assert db.try_acquire_session_turn_lease("current", holder, ttl_seconds=120) + goal_key = "goal:historical" + goal = '{"status":"cleared","outcome":"CANCELLED","checkpoint":"retained"}' + db.set_meta(goal_key, goal) + source = tmp_path / "source.dat" + source.write_bytes(b"source before") + source_manifest = json.dumps({str(source): sha(source.read_bytes())}).encode() + files = {} + for role, raw in {"plan": b"plan", "contract": b"contract", "source": source_manifest}.items(): + path = tmp_path / (role + ".json") + path.write_bytes(raw) + files[role] = str(path) + members = (("historical-goal-key", goal_key), ("authority", "No implicit effect retry")) + files["members"] = {} + for name, raw in members: + path = tmp_path / (name + ".txt") + path.write_text(raw) + files["members"][name] = str(path) + checkpoint = RunCheckpoint( + plan_digest=sha(b"plan"), contract_digest=sha(b"contract"), + source_digest=sha(source_manifest), next_action="Reconcile ambiguous push, do not retry", + members=members, unresolved_effects=("push:unknown",), + unresolved_findings=("FTS:open",), restrictions=("no live activation",), + ) + owner = db.claim_run_custody("resume-fixture", expected_generation=0, + checkpoint=checkpoint, origin_session_id="origin", current_session_id="current", + historical_goal_digest=sha(goal.encode()), ttl_seconds=120) + request = {"checkpoint": dataclasses.asdict(checkpoint), "files": files, + "session_id": "current", "lease_holder": holder} + request_path = tmp_path / "request.json" + request_path.write_text(json.dumps(request)) + yield db, owner, request, request_path, source + db.close() + + +def invoke(client, bound, generation=1): + db, owner, request, request_path, source = bound + request_path.write_text(json.dumps(request)) + return client.inspect_resume(db.db_path, "resume-fixture", generation, request_path) + + +def native_rows(db): + with db._read_ctx() as conn: + return tuple(conn.execute("SELECT key,value FROM state_meta ORDER BY key").fetchall()) + + +def test_real_consumer_reads_native_state_without_authority_or_mutation(client, bound): + db, owner, *_ = bound + before = native_rows(db) + result = invoke(client, bound) + assert result["status"] == "resume_consistency" + assert result["resume_authorized"] is False + assert result["effects_executed"] is False + assert result["unresolved_effects"] == 1 + assert result["generation"] == owner.generation + assert owner.owner_token not in json.dumps(result) + assert before == native_rows(db) + + +def test_subprocess_restart_reads_same_checkpoint_and_redacts_tokens(client, bound): + db, owner, request, request_path, source = bound + before = native_rows(db) + command = [sys.executable, str(SCRIPT), "--db", str(db.db_path), + "--run-id", "resume-fixture", "--generation", "1", "--request", str(request_path)] + for _ in range(2): + child = subprocess.run(command, capture_output=True, text=True, timeout=20) + assert child.returncode == 0, child.stderr + child.stdout + output = json.loads(child.stdout) + assert output["status"] == "resume_consistency" + assert output["resume_authorized"] is False + assert owner.owner_token not in child.stdout + child.stderr + assert request["lease_holder"] not in child.stdout + child.stderr + assert before == native_rows(db) + + +@pytest.mark.parametrize("field", ["unresolved_effects", "unresolved_findings", "restrictions", "members"]) +def test_candidate_cannot_omit_native_inventory(client, bound, field): + db, owner, request, *_ = bound + before = native_rows(db) + request["checkpoint"][field] = [] + with pytest.raises(client.ResumeRefusal, match="CHECKPOINT_MISMATCH"): + invoke(client, bound) + assert before == native_rows(db) + + +def test_self_consistent_candidate_cannot_replace_native_digest(client, bound): + db, owner, request, request_path, source = bound + source.write_bytes(b"substitution") + manifest = json.dumps({str(source): sha(source.read_bytes())}).encode() + Path(request["files"]["source"]).write_bytes(manifest) + request["checkpoint"]["source_digest"] = sha(manifest) + with pytest.raises(client.ResumeRefusal, match="CHECKPOINT_MISMATCH"): + invoke(client, bound) + + +@pytest.mark.parametrize("role", ["plan", "contract", "source", "member", "source_file"]) +def test_actual_file_drift_refuses_even_when_export_is_unchanged(client, bound, role): + db, owner, request, request_path, source = bound + path = source if role == "source_file" else Path( + request["files"]["members"]["authority"] if role == "member" else request["files"][role]) + path.write_bytes(b"drift") + before = native_rows(db) + with pytest.raises(client.ResumeRefusal, match="FILE_DIGEST_MISMATCH|MEMBER_MISMATCH"): + invoke(client, bound) + assert before == native_rows(db) + + +def test_missing_member_binding_refuses(client, bound): + bound[2]["files"]["members"].pop("authority") + with pytest.raises(client.ResumeRefusal, match="MEMBER_BINDINGS_MISMATCH"): + invoke(client, bound) + + +@pytest.mark.parametrize("fault", ["missing", "wrong", "expired", "session"]) +def test_native_session_lease_is_checked(client, bound, fault): + db, owner, request, *_ = bound + if fault == "missing": + db.release_session_turn_lease("current", request["lease_holder"]) + elif fault == "wrong": + request["lease_holder"] = "not-owner" + elif fault == "expired": + db._execute_write(lambda conn: conn.execute("UPDATE session_turn_leases SET expires_at=0")) + else: + request["session_id"] = "absent" + before = native_rows(db) + with pytest.raises(client.ResumeRefusal, match="SESSION_MISMATCH|LEASE_MISMATCH"): + invoke(client, bound) + assert before == native_rows(db) + + +@pytest.mark.parametrize("reason", ["compression", "user_exit"]) +def test_closed_session_is_not_a_current_resume_target(client, bound, reason): + db, *_ = bound + db.end_session("current", reason) + before = native_rows(db) + with pytest.raises(client.ResumeRefusal, match="SESSION_NOT_CURRENT"): + invoke(client, bound) + assert before == native_rows(db) + + +def test_historical_goal_change_refuses_without_revival(client, bound): + db, *_ = bound + db.set_meta("goal:historical", '{"status":"changed"}') + before = native_rows(db) + with pytest.raises(client.ResumeRefusal, match="HISTORICAL_GOAL_MISMATCH"): + invoke(client, bound) + assert before == native_rows(db) + + +def test_stale_generation_refuses(client, bound): + with pytest.raises(client.ResumeRefusal, match="FENCE_MISMATCH"): + invoke(client, bound, generation=2) + + +def test_released_owner_refuses(client, bound): + db, owner, *_ = bound + db.release_run_custody("resume-fixture", owner_token=owner.owner_token, expected_generation=1) + with pytest.raises(client.ResumeRefusal, match="STALE_OWNER"): + invoke(client, bound, generation=2) + + +def test_native_update_during_file_read_is_detected(client, bound, monkeypatch): + db, owner, *_ = bound + real_read = client.read_file_bytes + fired = False + def read_and_publish(path): + nonlocal fired + raw = real_read(path) + if not fired and str(path).endswith("plan.json"): + fired = True + db.refresh_run_custody("resume-fixture", owner_token=owner.owner_token, expected_generation=1) + return raw + monkeypatch.setattr(client, "read_file_bytes", read_and_publish) + with pytest.raises(client.ResumeRefusal, match="NATIVE_STATE_CHANGED|FENCE_MISMATCH"): + invoke(client, bound) + assert fired + assert db.read_run_custody("resume-fixture").generation == 2 + + +def test_read_only_sessiondb_rejects_write(client, bound): + db, *_ = bound + readonly = SessionDB(db_path=db.db_path, read_only=True) + try: + with pytest.raises(Exception, match="read.only|readonly"): + readonly.set_meta("forbidden", "value") + finally: + readonly.close() + assert db.get_meta("forbidden") is None + + +def test_nonexistent_store_is_not_created(client, bound, tmp_path): + missing = tmp_path / "missing.db" + with pytest.raises(client.ResumeRefusal, match="INVALID_DB"): + client.inspect_resume(missing, "resume-fixture", 1, bound[3]) + assert not missing.exists() + + +def test_duplicate_request_keys_refuse(client, bound): + db, owner, request, path, source = bound + path.write_text('{"checkpoint":{},"checkpoint":{}}') + with pytest.raises(client.ResumeRefusal, match="DUPLICATE_KEY"): + client.inspect_resume(db.db_path, "resume-fixture", 1, path) + + +@pytest.mark.linux_only +def test_symlink_and_fifo_are_refused_without_blocking(client, bound, tmp_path): + target = tmp_path / "link" + target.symlink_to(bound[4]) + bound[2]["files"]["plan"] = str(target) + with pytest.raises(client.ResumeRefusal, match="INVALID_FILE"): + invoke(client, bound) + fifo = tmp_path / "fifo" + os.mkfifo(fifo) + bound[2]["files"]["plan"] = str(fifo) + with pytest.raises(client.ResumeRefusal, match="INVALID_FILE"): + invoke(client, bound) + + +def test_missing_request_argument_has_no_default_store(client, tmp_path): + child = subprocess.run([sys.executable, str(SCRIPT)], cwd=tmp_path, + capture_output=True, text=True, timeout=20) + assert child.returncode == 2 + assert not (tmp_path / "state.db").exists() diff --git a/tests/test_state_meta_cas_atomicity.py b/tests/test_state_meta_cas_atomicity.py new file mode 100644 index 000000000000..b9e849445417 --- /dev/null +++ b/tests/test_state_meta_cas_atomicity.py @@ -0,0 +1,143 @@ +"""Native metadata CAS witnesses; these do not certify run custody.""" + +import json +import os +from pathlib import Path +import sqlite3 +import subprocess +import sys + +import pytest + +import hermes_state +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path): + store = SessionDB(db_path=tmp_path / "state.db") + try: + yield store + finally: + store.close() + + +@pytest.mark.parametrize("existing", [None, "old"]) +def test_duplicate_batch_keys_refused_without_any_write(db, existing): + if existing is not None: + db.set_meta("head", existing) + with pytest.raises(ValueError, match="duplicate"): + db.compare_and_set_meta_many([ + ("unrelated", None, "must-not-land"), + ("head", existing, "first"), + ("head", existing, "second"), + ]) + assert db.get_meta("head") == existing + assert db.get_meta("unrelated") is None + + +def test_keys_colliding_after_existing_string_conversion_are_duplicates(db): + with pytest.raises(ValueError, match="duplicate"): + db.compare_and_set_meta_many([(1, None, "first"), ("1", None, "second")]) + assert db.get_meta("1") is None + + +def test_failed_preimage_keeps_whole_batch_unchanged(db): + db.set_meta("head", "old") + assert not db.compare_and_set_meta_many([ + ("member", None, "candidate"), ("head", "stale", "new") + ]) + assert db.get_meta("head") == "old" + assert db.get_meta("member") is None + + +def test_valid_batch_and_lost_ack_readback(db): + assert db.compare_and_set_meta_many([ + ("head", None, "generation:1"), ("member:1", None, "payload") + ]) + # A retry after losing acknowledgement cannot append a second generation. + assert not db.compare_and_set_meta_many([ + ("head", None, "generation:1"), ("member:1", None, "payload") + ]) + assert db.get_meta("head") == "generation:1" + assert db.get_meta("member:1") == "payload" + assert db.compare_and_set_meta_many([]) + + +def test_sql_failure_rolls_back_earlier_batch_members(db): + db.set_meta("head", "old") + db._conn.execute( + "CREATE TRIGGER reject_member BEFORE INSERT ON state_meta " + "WHEN NEW.key='member:bad' BEGIN SELECT RAISE(ABORT, 'injected'); END" + ) + with pytest.raises(sqlite3.IntegrityError, match="injected"): + db.compare_and_set_meta_many([ + ("head", "old", "new"), ("member:bad", None, "payload") + ]) + assert db.get_meta("head") == "old" + assert db.get_meta("member:bad") is None + + +_CONTENDER = r''' +import json, sys +from pathlib import Path +from hermes_state import SessionDB +store = SessionDB(db_path=Path(sys.argv[1])) +print("ready", flush=True) +assert sys.stdin.readline().strip() == "go" +won = store.compare_and_set_meta_many([ + ("head", "old", sys.argv[2]), + ("member:" + sys.argv[2], None, "payload:" + sys.argv[2]), +]) +print(json.dumps({"won": won}), flush=True) +store.close() +''' + + +def test_two_processes_one_winner_and_no_losing_member(tmp_path): + path = tmp_path / "state.db" + with_store = SessionDB(db_path=path) + with_store.set_meta("head", "old") + with_store.close() + workers = [] + try: + for label in ("first", "second"): + workers.append(subprocess.Popen( + [sys.executable, "-c", _CONTENDER, str(path), label], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, + env={ + **os.environ, + "HERMES_HOME": str(tmp_path / label), + "PYTHONPATH": str(Path(hermes_state.__file__).resolve().parent), + }, + )) + # Parent-controlled barrier: both independent stores are open before CAS. + for worker in workers: + ready = worker.stdout.readline().strip() + if ready != "ready": + stdout, stderr = worker.communicate(timeout=10) + pytest.fail(f"contender setup failed: {ready!r} {stdout} {stderr}") + for worker in workers: + worker.stdin.write("go\n") + worker.stdin.flush() + results = [] + for worker in workers: + stdout, stderr = worker.communicate(timeout=20) + assert worker.returncode == 0, stderr + results.append(json.loads(stdout)["won"]) + assert sorted(results) == [False, True] + store = SessionDB(db_path=path) + try: + winner = ("first", "second")[results.index(True)] + loser = ("first", "second")[results.index(False)] + assert store.get_meta("head") == winner + assert store.get_meta("member:" + winner) == "payload:" + winner + assert store.get_meta("member:" + loser) is None + finally: + store.close() + finally: + for worker in workers: + if worker.poll() is None: + worker.kill() + worker.communicate(timeout=10) diff --git a/tests/test_turn_run_custody.py b/tests/test_turn_run_custody.py new file mode 100644 index 000000000000..c1d909490f1f --- /dev/null +++ b/tests/test_turn_run_custody.py @@ -0,0 +1,195 @@ +"""Turn-owned private custody handles over real SessionDB primitives.""" +from dataclasses import asdict +import hashlib +import importlib +import importlib.util +import json +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint +from scripts.run_checkpoint_claim import ClaimOutcomeUnknown, ClaimRefusal + + +def sha(value): + return hashlib.sha256(value).hexdigest() + + +def owner_class(): + assert importlib.util.find_spec("agent.run_checkpoint_custody"), "turn custody owner missing" + return importlib.import_module("agent.run_checkpoint_custody").TurnRunCustody + + +def assert_unknown(operation): + try: + operation() + except ClaimOutcomeUnknown: + return + except BaseException as exc: + pytest.fail(f"Unclassified native outcome: {type(exc).__name__}") + pytest.fail("Expected an unknown native outcome") + + +@pytest.fixture +def fixture(tmp_path): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("current", source="desktop") + assert db.try_acquire_session_turn_lease("current", "holder", ttl_seconds=120) + goal = '{"status":"cleared","outcome":"CANCELLED"}' + db.set_meta("goal:old", goal) + source = tmp_path / "source.bin" + source.write_bytes(b"source") + inventory = json.dumps({str(source): sha(source.read_bytes())}).encode() + files = {"members": {}} + for name, data in {"plan": b"plan", "contract": b"contract", "source": inventory}.items(): + path = tmp_path / name + path.write_bytes(data) + files[name] = str(path) + members = (("historical-goal-key", "goal:old"), ("scope", "no effects")) + for name, text in members: + path = tmp_path / (name + ".txt") + path.write_text(text) + files["members"][name] = str(path) + cp = RunCheckpoint(sha(b"plan"), sha(b"contract"), sha(inventory), "observe", members, + ("effect:unknown",), ("finding:open",), ("no activation",)) + request = tmp_path / "request.json" + request.write_text(json.dumps({"checkpoint": asdict(cp), "files": files, + "session_id": "current", "lease_holder": "holder"})) + args = dict(run_id="turn-fixture", expected_generation=0, session_id="current", + request_path=str(request), expected_request_digest=sha(request.read_bytes()), + origin_session_id="old", historical_goal_digest=sha(goal.encode()), ttl_seconds=120) + yield db, args, goal + db.close() + + +def started(fixture): + db, args, _ = fixture + owner = owner_class()(db) + owner.begin_turn("holder") + return owner, args + + +def test_claim_refresh_finish_releases_private_handle_and_preserves_history(fixture): + db, _, goal = fixture + owner, args = started(fixture) + response = owner.claim("holder", **args) + assert response["generation"] == 1 + native = db.read_run_custody(args["run_id"]) + assert native.owner_token not in json.dumps(response) + assert native.owner_token not in repr(owner) + refreshed = owner.refresh("holder", run_id=args["run_id"], expected_generation=1, ttl_seconds=120) + assert refreshed["generation"] == 2 + assert refreshed["resume_authorized"] is False + assert owner.finish_turn("holder") == [] + released = db.read_run_custody(args["run_id"]) + assert released.disposition == "released" + assert released.generation == 3 + assert released.checkpoint == native.checkpoint + assert db.get_meta("goal:old") == goal + assert owner.finish_turn("holder") == [] + assert db.read_run_custody(args["run_id"]) == released + with pytest.raises(ClaimRefusal, match="TURN_NOT_ACTIVE"): + owner.claim("holder", **args) + + +def test_explicit_release_and_new_turn_reclaim_without_process_restart(fixture): + db, _, _ = fixture + owner, args = started(fixture) + owner.claim("holder", **args) + first = db.read_run_custody(args["run_id"]) + assert owner.release("holder", run_id=args["run_id"], expected_generation=1)["generation"] == 2 + assert owner.finish_turn("holder") == [] + db.release_session_turn_lease("current", "holder") + assert db.try_acquire_session_turn_lease("current", "next-holder", ttl_seconds=120) + from pathlib import Path + request = Path(args["request_path"]) + value = json.loads(request.read_text()) + value["lease_holder"] = "next-holder" + request.write_text(json.dumps(value)) + owner.begin_turn("next-holder") + response = owner.claim("next-holder", **{**args, "expected_generation": 2, + "expected_request_digest": sha(request.read_bytes())}) + assert response["generation"] == 3 + assert db.read_run_custody(args["run_id"]).owner_token != first.owner_token + with pytest.raises(ClaimRefusal, match="TURN_NOT_ACTIVE"): + owner.refresh("holder", run_id=args["run_id"], expected_generation=3, ttl_seconds=120) + assert owner.finish_turn("next-holder") == [] + + +def test_finish_can_release_an_expired_handle(fixture, monkeypatch): + import hermes_state_runs + db, _, _ = fixture + owner, args = started(fixture) + owner.claim("holder", **args) + native = db.read_run_custody(args["run_id"]) + monkeypatch.setattr(hermes_state_runs.time, "monotonic_ns", lambda: native.expires_monotonic_ns + 1) + assert owner.finish_turn("holder") == [] + assert db.read_run_custody(args["run_id"]).disposition == "released" + + +@pytest.mark.parametrize("error_type", [OSError, KeyboardInterrupt]) +def test_lost_claim_ack_remains_unknown_and_is_not_retried_on_finish(fixture, monkeypatch, error_type): + db, _, _ = fixture + owner, args = started(fixture) + original = db.claim_run_custody_checked + calls = [] + def lost(*a, **kw): + calls.append(1) + original(*a, **kw) + raise error_type("lost native acknowledgement") + monkeypatch.setattr(db, "claim_run_custody_checked", lost) + assert_unknown(lambda: owner.claim("holder", **args)) + errors = owner.finish_turn("holder") + assert errors[0]["status"] == "unknown" + assert errors[0]["run_id"] == args["run_id"] + assert len(calls) == 1 + assert db.read_run_custody(args["run_id"]).disposition == "active" + owner.begin_turn("next-holder") + with pytest.raises(ClaimOutcomeUnknown): + owner.claim("next-holder", **args) + assert len(calls) == 1 + + +@pytest.mark.parametrize("error_type", [OSError, KeyboardInterrupt]) +def test_lost_release_ack_is_retained_without_an_automatic_second_release(fixture, monkeypatch, error_type): + db, _, _ = fixture + owner, args = started(fixture) + owner.claim("holder", **args) + original = db.release_run_custody + calls = [] + def lost(*a, **kw): + calls.append(1) + original(*a, **kw) + raise error_type("lost release acknowledgement") + monkeypatch.setattr(db, "release_run_custody", lost) + assert_unknown(lambda: owner.release("holder", run_id=args["run_id"], expected_generation=1)) + assert owner.finish_turn("holder")[0]["status"] == "unknown" + assert len(calls) == 1 + assert db.read_run_custody(args["run_id"]).disposition == "released" + + +def test_finish_serializes_with_inflight_claim(fixture, monkeypatch): + db, _, _ = fixture + owner, args = started(fixture) + entered, release, finishing = threading.Event(), threading.Event(), threading.Event() + original = db.claim_run_custody_checked + def blocked(*a, **kw): + entered.set() + assert release.wait(10) + return original(*a, **kw) + monkeypatch.setattr(db, "claim_run_custody_checked", blocked) + def finish(): + finishing.set() + return owner.finish_turn("holder") + with ThreadPoolExecutor(max_workers=2) as pool: + claim_future = pool.submit(owner.claim, "holder", **args) + assert entered.wait(10) + finish_future = pool.submit(finish) + assert finishing.wait(10) + release.set() + assert claim_future.result(timeout=10)["generation"] == 1 + assert finish_future.result(timeout=10) == [] + assert db.read_run_custody(args["run_id"]).disposition == "released" diff --git a/tests/tui_gateway/test_compute_host_phase1.py b/tests/tui_gateway/test_compute_host_phase1.py index b362bcb4b642..f2c2a4570051 100644 --- a/tests/tui_gateway/test_compute_host_phase1.py +++ b/tests/tui_gateway/test_compute_host_phase1.py @@ -36,6 +36,25 @@ def _wait_for_frame(out: io.StringIO, predicate, timeout: float = 2.0) -> dict: raise AssertionError(f"timed out waiting for frame; saw={_json_lines(out)}") +def test_host_interrupt_clears_child_pending_prompt(monkeypatch): + monkeypatch.setenv("HERMES_COMPUTE_HOST_CHILD", "1") + pending = threading.Event() + agent = types.SimpleNamespace(interrupt=lambda *a, **k: None, session_id="current") + session = {"agent": agent, "running": True, "history_lock": threading.Lock(), + "session_key": "current", "source": "desktop", + "_run_thread": types.SimpleNamespace(is_alive=lambda: True)} + monkeypatch.setattr(server, "_sessions", {"child": session}) + monkeypatch.setattr(server, "_pending", {"prompt": ("child", pending)}) + monkeypatch.setattr(server, "_finalize_session", lambda *a, **k: None) + host = ComputeHost(stdout=io.StringIO(), heartbeat_secs=0) + try: + host._handle_interrupt({"sid": "child", "request_id": "stop"}) + assert pending.is_set(), "child-owned blocking prompt must be woken on Stop" + assert session["_turn_cancel_requested"] is True + finally: + host.close() + + def test_ensure_server_session_fallback_uses_canonical_source_resolver(monkeypatch): """A side-machinery failure must not abort a real host turn. @@ -151,6 +170,9 @@ def test_mutator_route_table_matches_prd_inventory(): "session.interrupt": "turn-path", "reload.mcp": "run-concurrent", "session.save": "run-concurrent", + "session.run_checkpoint.claim": "run-concurrent", + "session.run_checkpoint.refresh": "run-concurrent", + "session.run_checkpoint.release": "run-concurrent", "session.compress": "idle-gated", "prompt.submit.truncate": "idle-gated", "slash.model": "idle-gated", diff --git a/tests/tui_gateway/test_run_checkpoint_claim_rpc.py b/tests/tui_gateway/test_run_checkpoint_claim_rpc.py new file mode 100644 index 000000000000..a6788bd89021 --- /dev/null +++ b/tests/tui_gateway/test_run_checkpoint_claim_rpc.py @@ -0,0 +1,418 @@ +"""Real RPC dispatch and native checkpoint admission; no model/provider call.""" +import dataclasses +import hashlib +import json +import os +from pathlib import Path +import queue +import sqlite3 +import threading +from types import SimpleNamespace + +import pytest + +from hermes_state import SessionDB +from hermes_state_runs import RunCheckpoint +from tui_gateway import server + + +METHOD = "session.run_checkpoint.claim" + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +class ReplyTransport: + def __init__(self): + self.responses = queue.Queue() + + def write(self, response): + self.responses.put(response) + return True + + +def dispatch(f, params=None, transport=None): + transport = transport or f.transport + result = server.dispatch({"jsonrpc": "2.0", "id": 1, "method": METHOD, + "params": f.params if params is None else params}, transport) + return result if result is not None else transport.responses.get(timeout=10) + + +def rows(db): + with sqlite3.connect(f"file:{db.db_path}?mode=ro", uri=True) as conn: + return conn.execute("SELECT key,value FROM state_meta ORDER BY key").fetchall() + + +@pytest.fixture +def live(tmp_path, monkeypatch): + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session(session_id="current", source="desktop") + assert db.try_acquire_session_turn_lease("current", "active-holder", ttl_seconds=120) + goal = '{"status":"cleared","outcome":"CANCELLED"}' + db.set_meta("goal:old", goal) + source = tmp_path / "source.bin" + source.write_bytes(b"source bytes") + inventory = json.dumps({str(source): sha(source.read_bytes())}).encode() + files = {} + for name, raw in {"plan": b"plan", "contract": b"contract", "source": inventory}.items(): + path = tmp_path / name + path.write_bytes(raw) + files[name] = str(path) + members = (("historical-goal-key", "goal:old"), ("authority", "No downstream effects")) + files["members"] = {} + for name, raw in members: + path = tmp_path / (name + ".txt") + path.write_text(raw) + files["members"][name] = str(path) + cp = RunCheckpoint(sha(b"plan"), sha(b"contract"), sha(inventory), "Reconcile only", + members, ("push:unknown",), ("FTS:open",), ("no activation",)) + request = {"checkpoint": dataclasses.asdict(cp), "files": files, + "session_id": "current", "lease_holder": "active-holder"} + request_path = tmp_path / "request.json" + request_path.write_text(json.dumps(request)) + params = dict(session_id="runtime-sid", run_id="rpc-fixture", expected_generation=0, + request_path=str(request_path), expected_request_digest=sha(request_path.read_bytes()), + origin_session_id="old", historical_goal_digest=sha(goal.encode()), ttl_seconds=120) + agent = SimpleNamespace(_session_db=db, session_id="current", + _active_session_turn_lease_holder="active-holder") + from agent.run_checkpoint_custody import TurnRunCustody + agent._run_checkpoint_custody = TurnRunCustody(db) + agent._run_checkpoint_custody.begin_turn("active-holder") + ready = threading.Event() + ready.set() + transport = ReplyTransport() + session = dict(agent=agent, agent_ready=ready, agent_error=None, running=True, + session_key="current", profile_home=str(tmp_path), transport=transport) + monkeypatch.setattr(server, "_sessions", {"runtime-sid": session}) + def forbidden(*a, **kw): + pytest.fail("claim RPC must not open, build, acquire or substitute an owner") + for name in ("_get_db", "_db_for_profile", "_session_db", "_make_agent", "_sess_building"): + monkeypatch.setattr(server, name, forbidden) + yield SimpleNamespace(db=db, agent=agent, session=session, cp=cp, request=request, + params=params, transport=transport, goal=goal, source=source) + # This fixture owns the temporary record; do not feed it to the global + # gateway teardown, which intentionally opens stores to finalize sessions. + server._sessions.clear() + db.close() + + +def rpc_method(f, name, params): + result = server.dispatch({"jsonrpc": "2.0", "id": 9, "method": name, "params": params}, f.transport) + return result if result is not None else f.transport.responses.get(timeout=10) + + +def test_rpc_manages_refresh_and_release_without_exposing_handle(live): + assert "result" in dispatch(live) + params = {"session_id": "runtime-sid", "run_id": "rpc-fixture", "expected_generation": 1, "ttl_seconds": 120} + refreshed = rpc_method(live, "session.run_checkpoint.refresh", params) + assert refreshed["result"]["generation"] == 2 + del params["ttl_seconds"] + params["expected_generation"] = 2 + released = rpc_method(live, "session.run_checkpoint.release", params) + assert released["result"]["status"] == "release_observed" + value = live.db.read_run_custody("rpc-fixture") + assert value.disposition == "released" + assert value.owner_token not in json.dumps([refreshed, released]) + assert "active-holder" not in json.dumps([refreshed, released]) + + +def test_gateway_routes_isolated_claim_without_using_local_owner(live, monkeypatch): + calls = [] + def control(sid, **kwargs): + calls.append((sid, kwargs)) + return {"type": "control.ack", "sid": sid, "route_name": METHOD, + "response": {"jsonrpc": "2.0", "id": "private", "result": {"status": "claim_observed"}}} + monkeypatch.setattr(server, "_session_uses_compute_host", lambda s: True) + monkeypatch.setattr(server, "_compute_host_supervisor", SimpleNamespace(control=control)) + live.session["_compute_host_active"] = True + live.agent._session_db = None + before = rows(live.db) + response = dispatch(live) + assert response["result"]["status"] == "claim_observed" + assert len(calls) == 1 + assert calls[0][1]["payload"]["params"] == live.params + assert rows(live.db) == before + + +def test_isolated_control_timeout_is_unknown_not_retried(live, monkeypatch): + calls = [] + def control(*a, **kw): + calls.append(1) + raise TimeoutError("lost acknowledgement") + monkeypatch.setattr(server, "_session_uses_compute_host", lambda s: True) + monkeypatch.setattr(server, "_compute_host_supervisor", SimpleNamespace(control=control)) + live.session["_compute_host_active"] = True + response = dispatch(live) + assert response["error"]["data"]["status"] == "unknown" + assert response["error"]["data"]["automatic_retry"] is False + assert len(calls) == 1 + + +def rewrite(f): + path = Path(f.params["request_path"]) + path.write_text(json.dumps(f.request)) + f.params["expected_request_digest"] = sha(path.read_bytes()) + + +def refused(response, code=None): + assert "error" in response, response + assert response["error"]["data"]["status"] == "refused", response + assert response["error"]["data"]["custody_changed"] is False + if code: + assert response["error"]["message"] == code + + +def test_rpc_dispatch_claims_real_owner_without_opening_or_closing_store(live): + response = dispatch(live) + assert "result" in response, response + result = response["result"] + assert result["status"] == "claim_observed" + assert result["generation"] == 1 + assert result["resume_authorized"] is False + assert result["downstream_effects_executed"] is False + owner = live.db.read_run_custody("rpc-fixture") + assert owner.checkpoint == live.cp + assert owner.controller_pid == os.getpid() + assert owner.current_session_id == live.agent.session_id + native = rows(live.db) + assert len(native) == 3 # historical goal, head, generation + assert ("goal:old", live.goal) in native + assert owner.owner_token not in json.dumps(response) + assert "active-holder" not in json.dumps(response) + live.db.set_meta("still-borrowed", "usable") + assert live.db.get_meta("still-borrowed") == "usable" + + +def test_claim_uses_worker_dispatch_so_file_reads_do_not_block_reader(live, monkeypatch): + from scripts import run_checkpoint_claim as client + entered, release = threading.Event(), threading.Event() + original = client.claim_from_files + def blocked(*a, **kw): + entered.set() + assert release.wait(8) + return original(*a, **kw) + monkeypatch.setattr(client, "claim_from_files", blocked) + try: + response = server.dispatch({"jsonrpc": "2.0", "id": 1, "method": METHOD, + "params": live.params}, live.transport) + assert response is None, response + assert entered.wait(5) + # An unrelated invalid method can still be answered by the reader. + ping = server.dispatch({"id": 2, "method": "no.such.method"}, live.transport) + assert ping["error"]["code"] == -32601 + finally: + release.set() + assert live.transport.responses.get(timeout=10)["result"]["generation"] == 1 + + +@pytest.mark.parametrize("field,value", [ + ("profile", "other"), ("db_path", "/other/state.db"), ("controller_pid", 1), + ("lease_holder", "active-holder"), ("current_session_id", "other"), + ("expected_generation", True), ("expected_generation", -1), + ("expected_generation", "0"), ("ttl_seconds", True), ("ttl_seconds", 0), + ("ttl_seconds", 3601), ("ttl_seconds", 1.5), ("ttl_seconds", float("inf")), + ("run_id", "../other"), ("run_id", ""), ("session_id", []), + ("origin_session_id", ""), ("expected_request_digest", "ABC"), + ("historical_goal_digest", "z" * 64), ("request_path", "relative.json"), + ("request_path", "\x00bad"), ("request_path", "/" + "x" * 4096), +]) +def test_rpc_exact_shape_and_type_refusals_leave_native_state_unchanged(live, field, value): + before = rows(live.db) + refused(dispatch(live, {**live.params, field: value}), "INVALID_PARAMS") + assert rows(live.db) == before + + +def test_missing_argument_is_not_defaulted(live): + params = dict(live.params) + del params["expected_generation"] + refused(dispatch(live, params), "INVALID_PARAMS") + assert live.db.read_run_custody("rpc-fixture") is None + + +@pytest.mark.parametrize("fault", ["db-missing", "db-closed", "db-read-only", "db-foreign", + "agent-missing", "not-ready", "agent-error", "idle", + "no-holder", "finalized", "cancelled", "removed", "transport"]) +def test_unavailable_or_foreign_live_owner_is_refused(live, tmp_path, fault): + before = rows(live.db) + extra = None + if fault == "db-missing": + live.agent._session_db = None + elif fault == "db-closed": + live.db.close() + elif fault == "db-read-only": + extra = SessionDB(db_path=live.db.db_path, read_only=True) + live.agent._session_db = extra + elif fault == "db-foreign": + extra = SessionDB(db_path=tmp_path / "foreign.db") + extra.create_session(session_id="current", source="desktop") + assert extra.try_acquire_session_turn_lease("current", "active-holder", ttl_seconds=120) + extra.set_meta("goal:old", live.goal) + live.agent._session_db = extra + elif fault == "agent-missing": + live.session["agent"] = None + elif fault == "not-ready": + live.session["agent_ready"].clear() + elif fault == "agent-error": + live.session["agent_error"] = "private error must not escape" + elif fault == "idle": + live.session["running"] = False + elif fault == "no-holder": + live.agent._active_session_turn_lease_holder = None + elif fault == "finalized": + live.session["_finalized"] = True + elif fault == "cancelled": + live.session["_turn_cancel_requested"] = True + elif fault == "removed": + server._sessions.clear() + try: + response = dispatch(live, transport=ReplyTransport() if fault == "transport" else None) + assert "error" in response + if fault != "removed": + refused(response) + assert "private error" not in json.dumps(response) + assert rows(live.db) == before + if extra: + assert extra.read_run_custody("rpc-fixture") is None + finally: + if extra: + extra.close() + + +@pytest.mark.parametrize("fault", ["request-session", "request-holder", "generation", "request-digest", + "request-missing", "source-changed"]) +def test_valid_shape_cannot_bypass_selected_agent_or_native_fence(live, fault): + if fault == "request-session": + live.db.create_session(session_id="other", source="desktop") + assert live.db.try_acquire_session_turn_lease("other", "active-holder", ttl_seconds=120) + live.request["session_id"] = "other" + rewrite(live) + elif fault == "request-holder": + # Native row/request agree, but not with the selected agent's active holder. + live.db.release_session_turn_lease("current", "active-holder") + assert live.db.try_acquire_session_turn_lease("current", "other-holder", ttl_seconds=120) + live.request["lease_holder"] = "other-holder" + rewrite(live) + elif fault == "generation": + live.params["expected_generation"] = 2 + elif fault == "request-digest": + live.params["expected_request_digest"] = "0" * 64 + elif fault == "request-missing": + Path(live.params["request_path"]).unlink() + else: + live.source.write_bytes(b"changed") + before = rows(live.db) + refused(dispatch(live)) + assert rows(live.db) == before + + +@pytest.mark.parametrize("fault,code", [("ack", "CLAIM_OUTCOME_UNKNOWN"), + ("readback", "CLAIM_READBACK_UNKNOWN"), ("mismatch", "CLAIM_READBACK_MISMATCH")]) +def test_post_commit_uncertainty_is_not_retry_or_refusal(live, monkeypatch, fault, code): + native_claim = live.db.claim_run_custody_checked + native_read = live.db.read_run_custody + calls = [] + def uncertain(*a, **kw): + calls.append(1) + value = native_claim(*a, **kw) + if fault == "ack": + raise OSError("private lost acknowledgement") + return value + monkeypatch.setattr(live.db, "claim_run_custody_checked", uncertain) + if fault == "readback": + def lost_read(*a): + raise OSError("private readback error") + monkeypatch.setattr(live.db, "read_run_custody", lost_read) + elif fault == "mismatch": + monkeypatch.setattr(live.db, "read_run_custody", lambda *a: None) + response = dispatch(live) + assert response["error"]["message"] == code + assert response["error"]["data"]["status"] == "unknown" + assert response["error"]["data"]["custody_changed"] is None + assert response["error"]["data"]["automatic_retry"] is False + assert "private" not in json.dumps(response) + assert len(calls) == 1 + owner = native_read("rpc-fixture") + assert owner.generation == 1 + assert owner.owner_token not in json.dumps(response) + before = rows(live.db) + # The new private handle preserves quarantine even on an explicit repeat; + # do not perform another native mutation to rediscover the unknown outcome. + repeated = dispatch(live) + assert repeated["error"]["data"]["status"] == "unknown" + assert len(calls) == 1 + assert rows(live.db) == before + + +def test_concurrent_rpc_claims_admit_exactly_one_generation(live, monkeypatch): + original = server._methods[METHOD] + barrier = threading.Barrier(2) + def race(*a, **kw): + barrier.wait(timeout=8) + return original(*a, **kw) + # Meet at the RPC boundary, before the per-owner serialization lock. + monkeypatch.setitem(server._methods, METHOD, race) + req = {"jsonrpc": "2.0", "id": 1, "method": METHOD, "params": live.params} + assert server.dispatch(req, live.transport) is None + assert server.dispatch({**req, "id": 2}, live.transport) is None + replies = [live.transport.responses.get(timeout=10) for _ in range(2)] + assert sum("result" in r for r in replies) == 1 + refused(next(r for r in replies if "error" in r), "FENCE_MISMATCH") + assert live.db.read_run_custody("rpc-fixture").generation == 1 + assert len(rows(live.db)) == 3 + + +@pytest.mark.parametrize("fault", ["session", "holder"]) +def test_selected_agent_and_native_binding_change_before_admission_is_refused(live, monkeypatch, fault): + original = live.db.claim_run_custody_checked + def changed(*a, **kw): + if fault == "session": + live.agent.session_id = "next" + live.db.create_session(session_id="next", source="desktop", parent_session_id="current") + live.db.end_session("current", "compression") + else: + live.agent._active_session_turn_lease_holder = "next-holder" + live.db.release_session_turn_lease("current", "active-holder") + assert live.db.try_acquire_session_turn_lease("current", "next-holder", ttl_seconds=120) + return original(*a, **kw) + monkeypatch.setattr(live.db, "claim_run_custody_checked", changed) + refused(dispatch(live)) + assert live.db.read_run_custody("rpc-fixture") is None + assert rows(live.db) == [("goal:old", live.goal)] + + +def test_launch_profile_uses_existing_launch_home_not_ambient_profile(live, monkeypatch): + live.session["profile_home"] = None + monkeypatch.setattr(server, "_hermes_home", str(Path(live.db.db_path).parent)) + monkeypatch.setenv("HERMES_HOME", "/unrelated/ambient/profile") + assert dispatch(live)["result"]["generation"] == 1 + + +def test_rpc_observes_request_bytes_once(live, monkeypatch): + from scripts import run_checkpoint_resume as observer + original = observer.read_file_bytes + calls = [] + def observed(path): + calls.append(path) + return original(path) + monkeypatch.setattr(observer, "read_file_bytes", observed) + assert dispatch(live)["result"]["generation"] == 1 + assert calls.count(live.params["request_path"]) == 1 + + +@pytest.mark.parametrize("fault", ["lease", "goal", "session"]) +def test_rpc_native_transaction_rechecks_after_file_observation(live, monkeypatch, fault): + original = live.db._execute_write + def interpose(write): + # Separate connection commits a native binding change just before admission. + with sqlite3.connect(live.db.db_path) as conn: + if fault == "lease": + conn.execute("DELETE FROM session_turn_leases") + elif fault == "goal": + conn.execute("UPDATE state_meta SET value='changed' WHERE key='goal:old'") + else: + conn.execute("UPDATE sessions SET ended_at=1,end_reason='compression' WHERE id='current'") + return original(write) + monkeypatch.setattr(live.db, "_execute_write", interpose) + refused(dispatch(live)) + assert live.db.read_run_custody("rpc-fixture") is None diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 05ff08d72eff..b52200a796db 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -303,7 +303,7 @@ # installs so computer_use never dead-ends on `No module named 'mcp'`. "tool.computer_use": ( "mcp==2.0.0", - "httpx2==2.7.0", # mcp 2.x HTTP stack — keep in sync with pyproject [computer-use] + "httpx2==2.12.0", # mcp 2.x HTTP stack — keep in sync with pyproject [computer-use] "starlette==1.3.1", # CVE-2026-48710 — keep in sync with pyproject [computer-use] ), # HF Agent Trace Viewer upload (hermes trace upload / /upload-trace). diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py index 3cba3909c660..b8915814a9be 100644 --- a/tui_gateway/compute_host.py +++ b/tui_gateway/compute_host.py @@ -403,14 +403,10 @@ def _handle_interrupt(self, frame: dict[str, Any]) -> None: if session is None: self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": False}) return - agent = session.get("agent") - if agent is not None: - request_hard_interrupt(agent) - with session.get("history_lock", threading.Lock()): - session["_turn_cancel_requested"] = True - session["queued_prompt"] = None - session.pop("queued_prompts", None) - session["_queued_prompt_generation"] = int(session.get("_queued_prompt_generation", 0)) + 1 + # Use the same owner-side Stop contract as a non-isolated turn: + # hard interrupt plus pending clarify/approval wakeup and queue clear. + # The child marker disables forwarding back through a supervisor. + server._interrupt_session_turn(sid, session, request_id=frame.get("request_id")) self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": True, "applied_ns": now_ns()}) except Exception as exc: self.emit({"type": "interrupt.ack", "sid": sid, "request_id": frame.get("request_id"), "applied": False, "message": str(exc)}) @@ -718,6 +714,22 @@ def _handle_control(self, frame: dict[str, Any]) -> None: if route == "idle-gated" and session.get("running"): self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": "session busy"}) return + if route_name in {"session.run_checkpoint.claim", "session.run_checkpoint.refresh", "session.run_checkpoint.release"}: + from tui_gateway.transport import bind_transport, reset_transport + params = frame.get("params") + if type(params) is not dict or params.get("session_id") != sid: + self.emit({"type": "control.error", "sid": sid, "request_id": request_id, "message": "checkpoint session mismatch"}) + return + transport_token = bind_transport(self._transport) + session_token = server._current_runtime_session_record.set(session) + try: + response = server._methods[route_name](request_id, params) + finally: + server._current_runtime_session_record.reset(session_token) + reset_transport(transport_token) + self.emit({"type": "control.ack", "sid": sid, "request_id": request_id, + "route_name": route_name, "response": response}) + return if route_name == "config.set.model": params = frame.get("params") if not isinstance(params, dict): diff --git a/tui_gateway/host_supervisor.py b/tui_gateway/host_supervisor.py index a65b2bf62a89..7f9622a25fd3 100644 --- a/tui_gateway/host_supervisor.py +++ b/tui_gateway/host_supervisor.py @@ -34,6 +34,9 @@ "session.interrupt": "turn-path", "reload.mcp": "run-concurrent", "session.save": "run-concurrent", + "session.run_checkpoint.claim": "run-concurrent", + "session.run_checkpoint.refresh": "run-concurrent", + "session.run_checkpoint.release": "run-concurrent", "session.compress": "idle-gated", "prompt.submit.truncate": "idle-gated", "slash.model": "idle-gated", diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index cf9f3d883d8e..45d1c1320ebb 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -11,6 +11,27 @@ _profile_scoped = _registry.profile_scoped +@method("session.run_checkpoint.claim") +def _(rid, params: dict) -> dict: + from tui_gateway import server + from tui_gateway.run_checkpoint_rpc import handle + return handle(server, rid, params, "claim") + + +@method("session.run_checkpoint.refresh") +def _(rid, params: dict) -> dict: + from tui_gateway import server + from tui_gateway.run_checkpoint_rpc import handle + return handle(server, rid, params, "refresh") + + +@method("session.run_checkpoint.release") +def _(rid, params: dict) -> dict: + from tui_gateway import server + from tui_gateway.run_checkpoint_rpc import handle + return handle(server, rid, params, "release") + + @method("session.create") def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] diff --git a/tui_gateway/run_checkpoint_rpc.py b/tui_gateway/run_checkpoint_rpc.py new file mode 100644 index 000000000000..a639d5eedc1a --- /dev/null +++ b/tui_gateway/run_checkpoint_rpc.py @@ -0,0 +1,107 @@ +"""Checkpoint RPC routing and live-owner checks on the existing gateway.""" +from pathlib import Path + +from agent.run_checkpoint_custody import TurnRunCustody +from hermes_state import SessionDB +from hermes_state_runs import RunCustodyError, _digest, _integer, _run_id, _ttl +from scripts.run_checkpoint_claim import ClaimOutcomeUnknown, ClaimRefusal + + +def handle(server, rid, params, operation): + def refuse(code, rpc_code=-32040): + return server._err(rid, rpc_code, code, {"status": "refused", "custody_changed": False, + "automatic_retry": False, "resume_authorized": False, "downstream_effects_executed": False}) + + def unknown(code): + return server._err(rid, -32041, code, {"status": "unknown", "custody_changed": None, + "automatic_retry": False, "resume_authorized": False, "downstream_effects_executed": False}) + + fields = {"session_id", "run_id", "expected_generation"} + if operation == "claim": + fields |= {"request_path", "expected_request_digest", "origin_session_id", "historical_goal_digest", "ttl_seconds"} + elif operation == "refresh": + fields.add("ttl_seconds") + elif operation != "release": + return refuse("INVALID_PARAMS", -32602) + if type(params) is not dict or set(params) != fields: + return refuse("INVALID_PARAMS", -32602) + try: + _run_id(params["run_id"]) + _integer(params["expected_generation"], 0 if operation == "claim" else 1) + if operation != "release": + _ttl(params["ttl_seconds"]) + for name in ("session_id", "origin_session_id") if operation == "claim" else ("session_id",): + value = params[name] + if type(value) is not str or not value.strip() or len(value) > 256 or "\x00" in value: + return refuse("INVALID_PARAMS", -32602) + if operation == "claim": + _digest(params["expected_request_digest"]) + _digest(params["historical_goal_digest"]) + path = params["request_path"] + if type(path) is not str or not path or len(path) > 4096 or "\x00" in path or not Path(path).is_absolute(): + return refuse("INVALID_PARAMS", -32602) + except RunCustodyError: + return refuse("INVALID_PARAMS", -32602) + + session, error = server._sess_nowait(params, rid) + if error: + return error + transport, selected = server._current_session_steer_authority(params["session_id"]) + if transport is None or selected is not session: + return refuse("LIVE_SESSION_MISMATCH") + if session.get("_finalized") or session.get("_turn_cancel_requested") or session.get("running") is not True: + return refuse("ACTIVE_AGENT_REQUIRED") + + if server._session_uses_compute_host(session): + supervisor = server._compute_host_supervisor + if supervisor is None or not session.get("_compute_host_active"): + return refuse("ACTIVE_COMPUTE_HOST_REQUIRED") + route = "session.run_checkpoint." + operation + try: + ack = supervisor.control(params["session_id"], route_name=route, + payload={"type": "control", "params": dict(params)}, wait=True, timeout=30.0) + except Exception: + return unknown("COMPUTE_HOST_OUTCOME_UNKNOWN") + if (type(ack) is not dict or ack.get("type") != "control.ack" or + ack.get("sid") != params["session_id"] or ack.get("route_name") != route or + type(ack.get("response")) is not dict): + return unknown("COMPUTE_HOST_READBACK_UNKNOWN") + response = ack["response"] + if set(response) not in ({"jsonrpc", "id", "result"}, {"jsonrpc", "id", "error"}): + return unknown("COMPUTE_HOST_READBACK_UNKNOWN") + return {**response, "id": rid} + + agent = session.get("agent") + ready = session.get("agent_ready") + # Directly initialized host sessions have no deferred-build Event. The + # actual turn-owned lifecycle and native lease, not an absent flag, bind them. + if agent is None or (ready is not None and not ready.is_set()) or session.get("agent_error"): + return refuse("ACTIVE_AGENT_REQUIRED") + sid = getattr(agent, "session_id", None) + holder = getattr(agent, "_active_session_turn_lease_holder", None) + if type(sid) is not str or not sid or type(holder) is not str or not holder: + return refuse("ACTIVE_AGENT_REQUIRED") + db = getattr(agent, "_session_db", None) + if not isinstance(db, SessionDB) or db.read_only or db._conn is None or db._read_conns_closed: + return refuse("WRITABLE_OWNER_REQUIRED") + try: + if Path(db.db_path).resolve(strict=True) != (server._session_home(session) / "state.db").resolve(strict=True): + return refuse("OWNER_STORE_MISMATCH") + except (OSError, ValueError, RuntimeError): + return refuse("OWNER_STORE_MISMATCH") + owner = getattr(agent, "_run_checkpoint_custody", None) + if not isinstance(owner, TurnRunCustody) or owner.db is not db: + return refuse("TURN_CUSTODY_OWNER_REQUIRED") + args = {key: value for key, value in params.items() if key != "session_id"} + try: + if operation == "claim": + result = owner.claim(holder, session_id=sid, **args) + elif operation == "refresh": + result = owner.refresh(holder, **args) + else: + result = owner.release(holder, **args) + except ClaimRefusal as exc: + return refuse(str(exc)) + except ClaimOutcomeUnknown as exc: + return unknown(str(exc)) + return server._ok(rid, result) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 79cad0f5d5ea..a5fe05a6b515 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -242,6 +242,11 @@ def _resolve_ws_orphan_reap_grace() -> float: "subscription.upgrade", "usage.bars", "session.usage", + # Bounded source/file observations and SQLite admission must not stall + # the reader's interrupt/approval path. No automatic claim or retry. + "session.run_checkpoint.claim", + "session.run_checkpoint.refresh", + "session.run_checkpoint.release", "billing.step_up", "browser.manage", "cli.exec", @@ -791,6 +796,28 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No """ if not session or session.get("_finalized"): return + from agent.run_checkpoint_custody import TurnRunCustody + agent = session.get("agent") + custody = getattr(agent, "_run_checkpoint_custody", None) + with session.get("history_lock") or _sessions_lock: + holder = getattr(agent, "_active_session_turn_lease_holder", None) + run_thread = session.get("_run_thread") + defer_custody = (isinstance(custody, TurnRunCustody) and holder and + (session.get("running") or (run_thread is not None and run_thread.is_alive()))) + if defer_custody: + session["_run_checkpoint_finalize_deferred"] = end_reason + if defer_custody: + # Never release custody, close relay scopes or finalize native session + # state underneath a live owning turn. Its finally is the release owner. + with _sessions_lock: + sid = session.get("_sid") or next((key for key, value in _sessions.items() if value is session), None) + if sid: + _interrupt_session_turn(sid, session) + else: + from agent.interrupt_compat import request_hard_interrupt + request_hard_interrupt(agent) + return + session.pop("_run_checkpoint_finalize_deferred", None) session["_finalized"] = True history_ready = session.get("resume_history_ready") if history_ready is not None and not history_ready.is_set(): @@ -802,6 +829,13 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No stop_event.set() agent = session.get("agent") + from agent.run_checkpoint_custody import TurnRunCustody + custody = getattr(agent, "_run_checkpoint_custody", None) + if isinstance(custody, TurnRunCustody): + errors = custody.finish_turn(getattr(agent, "_active_session_turn_lease_holder", None)) + if errors: + session["_run_checkpoint_cleanup_errors"] = errors + logger.error("Run checkpoint finalization requires reconciliation: %s", errors) lock = session.get("history_lock") if lock is not None: with lock: @@ -976,6 +1010,10 @@ def _teardown_session(session: dict | None, *, end_reason: str = "tui_close") -> if not session: return _finalize_session(session, end_reason=end_reason) + with session.get("history_lock") or _sessions_lock: + if session.get("_run_checkpoint_finalize_deferred"): + session["_run_checkpoint_teardown_deferred"] = end_reason + return _announce_session_reclaimed(session, end_reason) try: from tools.approval import unregister_gateway_notify @@ -12941,6 +12979,13 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: _retire_turn_marker(session, marker_key) session.pop("_auto_continue_scheduled", None) _emit_settled_session_info(sid, session, agent) + with session["history_lock"]: + deferred_teardown = session.pop("_run_checkpoint_teardown_deferred", None) + deferred_finalize = session.pop("_run_checkpoint_finalize_deferred", None) + if deferred_teardown: + _teardown_session(session, end_reason=deferred_teardown) + elif deferred_finalize: + _finalize_session(session, end_reason=deferred_finalize) # A user prompt that arrived mid-turn (interrupt + queue) wins over # every auto follow-up below — drain it first and skip them this cycle; diff --git a/ui-tui/package.json b/ui-tui/package.json index 977dbe4c3556..b7e36b9ca737 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -40,6 +40,6 @@ "prettier": "3.9.5", "tsx": "4.23.1", "typescript": "6.0.3", - "vitest": "4.1.10" + "vitest": "4.1.11" } } diff --git a/uv.lock b/uv.lock index 08586b2b3c27..0d60d8a37491 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-10T07:53:29.404877634Z" +exclude-newer = "2026-09-01T06:11:43.655297605Z" exclude-newer-span = "P14D" [options.exclude-newer-package] @@ -1804,15 +1804,15 @@ wheels = [ [[package]] name = "h2" -version = "4.3.0" +version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, ] [[package]] @@ -2121,9 +2121,9 @@ requires-dist = [ { name = "honcho-ai", marker = "extra == 'honcho'", specifier = "==2.2.0" }, { name = "httplib2", marker = "extra == 'google'", specifier = "==0.32.0" }, { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, - { name = "httpx2", marker = "extra == 'computer-use'", specifier = "==2.7.0" }, - { name = "httpx2", marker = "extra == 'dev'", specifier = "==2.7.0" }, - { name = "httpx2", marker = "extra == 'mcp'", specifier = "==2.7.0" }, + { name = "httpx2", marker = "extra == 'computer-use'", specifier = "==2.12.0" }, + { name = "httpx2", marker = "extra == 'dev'", specifier = "==2.12.0" }, + { name = "httpx2", marker = "extra == 'mcp'", specifier = "==2.12.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.6.8" }, { name = "markdown", specifier = "==3.10.2" }, @@ -2255,11 +2255,11 @@ wheels = [ [[package]] name = "hpack" -version = "4.1.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, ] [[package]] @@ -2277,15 +2277,15 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.7.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/fe/6a3f9f1a8bb8733326140737446aaf72fddb8b54b8f202302f5c84960613/httpcore2-2.7.0.tar.gz", hash = "sha256:6dc0fedf329a52a990930a5579edfebaea81118ea700ea0dd7de2b5e5be49efc", size = 65593, upload-time = "2026-07-14T20:40:01.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/6c/62e2e279e63fc4f7a5ee841ef13175a8bbc613f258e9dcc186e9de803a42/httpcore2-2.7.0-py3-none-any.whl", hash = "sha256:1452f589fe23f55b44546cd884294c41a29330af902bc0b71a761fd52d18f92b", size = 81506, upload-time = "2026-07-14T20:39:58.053Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] @@ -2370,18 +2370,28 @@ wheels = [ [[package]] name = "httpx2" -version = "2.7.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/4a/129b2e21b90ac2985d3928d96792bccc39bc6dfe796c5eee2d8ec06d4105/httpx2-2.7.0.tar.gz", hash = "sha256:8b30709aed5c8465b0dd3b95c09ce301c8f79e7e7a2d00ab0af551e0d0375b07", size = 94487, upload-time = "2026-07-14T20:40:02.318Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/b8/c341bba6411bdfda786020343c47a75ef472f6085caf82391b142b1a3ad9/httpx2-2.7.0-py3-none-any.whl", hash = "sha256:ed2a2719c696789e09493bd8e2bec3d8bd925cc6e26b68389ec25ade132f7bf4", size = 90234, upload-time = "2026-07-14T20:39:59.531Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -5132,19 +5142,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +version = "6.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, + { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, + { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, + { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, + { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, ] [[package]] diff --git a/web/package.json b/web/package.json index d26609aa9204..15eabc9a5d4d 100644 --- a/web/package.json +++ b/web/package.json @@ -54,6 +54,6 @@ "three": "0.180.0", "typescript": "6.0.3", "vite": "8.2.0", - "vitest": "4.1.10" + "vitest": "4.1.11" } } diff --git a/website/package-lock.json b/website/package-lock.json index c173631cb894..e2e84b855349 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -6625,9 +6625,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.43", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", - "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -6786,9 +6786,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.6", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", - "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -6805,11 +6805,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.42", - "caniuse-lite": "^1.0.30001803", - "electron-to-chromium": "^1.5.389", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6975,9 +6975,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -7284,9 +7284,9 @@ "license": "MIT" }, "node_modules/colord": { - "version": "2.9.3", - "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.4.tgz", + "integrity": "sha512-z0nTt5300AIviCn9FTEL4G2DzPkfEDfghx6Do0ZfM3u0rOINBtfw2H8lD4VXyZF/o8HTzzyiCztaG2X/+IMeZA==", "license": "MIT" }, "node_modules/colorette": { @@ -8998,9 +8998,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.392", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", - "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "version": "1.5.418", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.418.tgz", + "integrity": "sha512-UzS26r3AEbG5wSoGVpJKqwHIU9zwQN7LHdVIThDrJpS0I5KdlXFMEb8543fhc9dVnIIAST6ar8rhwa00AL5MlA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9555,9 +9555,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", @@ -11184,9 +11184,9 @@ } }, "node_modules/joi": { - "version": "17.13.4", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", - "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "version": "17.13.6", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.6.tgz", + "integrity": "sha512-ImNZaq/LSysofih+xIGYfR0WUXMA9GLUNB//YTCSrZptoRmVgaNAdJyi6K1kXi9pkLEoSkoI8I4UwtNiu/D7nw==", "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -11203,9 +11203,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -14086,6 +14086,24 @@ "multicast-dns": "cli.js" } }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", @@ -14127,9 +14145,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "license": "MIT", "engines": { "node": ">=18" @@ -16215,24 +16233,6 @@ "postcss": "^8.4.31" } }, - "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -16385,9 +16385,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -18135,9 +18135,9 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz", - "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "license": "MIT", "dependencies": { "commander": "^7.2.0", @@ -18703,9 +18703,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", diff --git a/website/package.json b/website/package.json index 220b182b814b..26ab440ccbcf 100644 --- a/website/package.json +++ b/website/package.json @@ -39,8 +39,16 @@ "serialize-javascript": "7.0.7", "uuid": "14.0.1", "minimatch": "10.2.6", - "nanoid": "3.3.17", - "js-yaml": "4.3.1", + "nanoid": "3.3.18", + "js-yaml": "4.3.2", + "baseline-browser-mapping": "2.11.20", + "browserslist": "4.28.8", + "qs": "6.16.0", + "colord": "2.9.4", + "fast-uri": "3.1.6", + "joi": "17.13.6", + "svgo": "3.3.5", + "sharp": "0.35.4", "dompurify": "3.4.13", "mermaid": "11.16.1", "image-size": "npm:@nous-research/image-size@2.0.3",