feat(plugins): add namespaced config and durable state bridge
This commit is contained in:
@@ -1,308 +1,144 @@
|
|||||||
# Plugin Config & State Bridge — Design Proposal
|
# Plugin Config & State Bridge
|
||||||
|
|
||||||
**Branch:** `feat/plugin-config-state-bridge`
|
**Status:** config + state slice implemented by #64227
|
||||||
**Target:** `hermes_cli/plugins.py` (PluginContext), `hermes_cli/config.py`, `cron/scheduler.py`
|
|
||||||
**Concrete consumer:** kanban-advanced plugin (config overlay, cron provisioning, dashboard)
|
|
||||||
|
|
||||||
---
|
**Original design:** Topher Ross (@thebizfixer), RFC PR #58542
|
||||||
|
|
||||||
## Summary
|
**Concrete consumer:** kanban-advanced
|
||||||
|
|
||||||
Today plugins that need to read/write Hermes configuration or manage cron jobs
|
## Scope
|
||||||
must shell out to CLI commands (`hermes config set`, `hermes cron create`) or
|
|
||||||
manipulate YAML/config files directly. This is fragile across platform
|
|
||||||
differences (Windows path separators, MSYS vs native Python), Hermes version
|
|
||||||
bumps, and concurrent access.
|
|
||||||
|
|
||||||
This proposal adds four capabilities to the `PluginContext` API so plugins can
|
This slice adds two native `PluginContext` capabilities:
|
||||||
integrate with Hermes' config and cron systems through stable, typed interfaces
|
|
||||||
without reaching into core internals.
|
|
||||||
|
|
||||||
---
|
- typed, namespace-jailed settings via `ctx.get_config()` and `ctx.set_config()`;
|
||||||
|
- atomic, profile-scoped runtime data via `ctx.state`.
|
||||||
|
|
||||||
## Proposal 1: `ctx.get_config()` / `ctx.set_config()`
|
Config schema registration, config defaults, and the cron facade from the
|
||||||
|
original RFC remain separate follow-up work. No core model tool is added.
|
||||||
|
|
||||||
### Current state
|
## Config API
|
||||||
Plugins read `config.yaml` via `hermes_cli.config.load_config()`, parse it
|
|
||||||
manually, and write back via direct file manipulation. This bypasses Hermes'
|
|
||||||
own config manager — no schema validation, no atomic writes, no migration
|
|
||||||
compatibility.
|
|
||||||
|
|
||||||
### Proposed API
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class PluginContext:
|
def register(ctx):
|
||||||
def get_config(self, key: str, default: Any = None) -> Any:
|
endpoint = ctx.get_config("api_url", default="https://example.invalid")
|
||||||
"""Read a config value by dotted key (e.g. 'kanban.dispatch_stale_timeout_seconds').
|
retries = ctx.get_config("retry.attempts", default=3)
|
||||||
|
|
||||||
Returns the default if the key is unset or the config file is missing.
|
ctx.set_config("api_url", "https://api.example.com")
|
||||||
Reads through Hermes' own config loader so layered configs (env overrides,
|
ctx.set_config("retry.attempts", 5)
|
||||||
profile-specific merges) are respected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def set_config(self, key: str, value: Any) -> None:
|
|
||||||
"""Write a config value by dotted key.
|
|
||||||
|
|
||||||
Writes through Hermes' config manager — atomic file write, schema
|
|
||||||
validation for known keys, migration compatibility. Raises ValueError
|
|
||||||
if the key path is invalid or the value fails schema validation.
|
|
||||||
"""
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementation sketch
|
Keys are **relative to the calling plugin**. The example above reads and writes:
|
||||||
|
|
||||||
```python
|
```yaml
|
||||||
# hermes_cli/plugins.py — PluginContext additions
|
plugins:
|
||||||
def get_config(self, key: str, default: Any = None) -> Any:
|
entries:
|
||||||
from hermes_cli.config import load_config, cfg_get
|
<effective-plugin-id>:
|
||||||
try:
|
settings:
|
||||||
config = load_config()
|
api_url: https://api.example.com
|
||||||
return cfg_get(config, key, default=default)
|
retry:
|
||||||
except Exception:
|
attempts: 5
|
||||||
return default
|
|
||||||
|
|
||||||
def set_config(self, key: str, value: Any) -> None:
|
|
||||||
# Deferred to #64227 implementation.
|
|
||||||
# When delivered, writes through the config manager with namespace jail
|
|
||||||
# enforcement (see § Namespace jail below).
|
|
||||||
raise NotImplementedError(
|
|
||||||
"ctx.set_config is pending implementation per #64227"
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `cfg_set` does not exist in the current codebase. The read path
|
`<effective-plugin-id>` is `manifest.key` when present, otherwise
|
||||||
> (`cfg_get` at `hermes_cli/config.py:6627`) is available. `set_config` will be
|
`manifest.name`. `settings` is the canonical namespace chosen after the issue
|
||||||
> delivered by #64227's implementation with the namespace jail described below.
|
discussion in #64227/#67531. For migration safety, reads fall back to the former
|
||||||
|
`plugins.entries.<id>.config.*` subtree only when the canonical value is absent.
|
||||||
### Concrete use case (kanban-advanced)
|
Writes always target `settings`; they do not rewrite or delete legacy values.
|
||||||
Our `config_overlay.py` currently does:
|
|
||||||
```python
|
|
||||||
# Current: fragile YAML manipulation
|
|
||||||
config = yaml.safe_load(Path(config_path).read_text())
|
|
||||||
config["kanban"]["dispatch_stale_timeout_seconds"] = 14400
|
|
||||||
Path(config_path).write_text(yaml.dump(config))
|
|
||||||
```
|
|
||||||
|
|
||||||
With this API:
|
|
||||||
```python
|
|
||||||
ctx.set_config("kanban.dispatch_stale_timeout_seconds", 14400)
|
|
||||||
ctx.set_config("kanban.auto_decompose", False)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Platform safety
|
|
||||||
On Windows, our config writes hit `re.sub` backslash escape bugs because
|
|
||||||
paths like `C:\Users\Owner` contain `\U` which Python interprets as a
|
|
||||||
Unicode escape in replacement strings. Going through Hermes' own config
|
|
||||||
manager eliminates this class of bug.
|
|
||||||
|
|
||||||
### Namespace jail
|
### Namespace jail
|
||||||
|
|
||||||
`set_config` keys are strictly namespaced to `plugins.entries.<id>.config.*`.
|
The API does not accept full config paths. A plugin can never use it to inspect
|
||||||
A plugin may not read or write config outside its own entry. Enforcement rules:
|
or change arbitrary Hermes configuration.
|
||||||
|
|
||||||
- **Key prefix:** all writes must start with `plugins.entries.<plugin_id>.config.`
|
Accepted:
|
||||||
- **Cross-plugin rejection:** writing another plugin's config subtree is rejected
|
|
||||||
with a logged error
|
|
||||||
- **Path-traversal rejection:** `../../security.approval_mode` and similar
|
|
||||||
escape attempts are rejected with a logged error
|
|
||||||
- **Read allow-list:** `ctx.get_config` can only read from
|
|
||||||
`plugins.entries.<id>.config.*` plus a small read-only allow-list
|
|
||||||
(e.g. active profile name, already available via `ctx.profile_name`)
|
|
||||||
|
|
||||||
Acceptance criteria (from #64227):
|
|
||||||
- Fixture plugin round-trips config + state through the bridge on a temp
|
|
||||||
`HERMES_HOME` (real file I/O, no mocks)
|
|
||||||
- Namespace-escape attempt (`../../security.*`, cross-plugin key, path
|
|
||||||
traversal) rejected with a logged error
|
|
||||||
- `hermes doctor` reports a plugin config value violating its registered schema
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposal 2: `ctx.register_config_schema()`
|
|
||||||
|
|
||||||
### Current state
|
|
||||||
Plugins with configuration (like kanban-advanced's `kanban-config.yaml`) have
|
|
||||||
JSON Schemas that are invisible to `hermes doctor`, `hermes config check`, and
|
|
||||||
the setup wizard. Users only discover configuration errors at runtime.
|
|
||||||
|
|
||||||
### Proposed API
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class PluginContext:
|
ctx.get_config("endpoint")
|
||||||
def register_config_schema(self, schema: dict) -> None:
|
ctx.set_config("retry.policy", {"attempts": 3})
|
||||||
"""Register a JSON Schema for this plugin's config namespace.
|
|
||||||
|
|
||||||
The schema validates keys under plugins.entries.<plugin_id>.config.
|
|
||||||
After registration, `hermes config check` validates the plugin's
|
|
||||||
config against this schema, and `hermes setup` can walk plugin
|
|
||||||
config interactively.
|
|
||||||
"""
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manifest support (plugin.yaml)
|
Rejected with `ValueError` and a warning log:
|
||||||
|
|
||||||
```yaml
|
|
||||||
# plugin.yaml — optional schema declaration
|
|
||||||
provides_config_schema: schema/kanban-config.schema.json
|
|
||||||
```
|
|
||||||
|
|
||||||
When present, the schema is loaded and registered automatically during plugin
|
|
||||||
init — no explicit `register_config_schema()` call needed in `__init__.py`.
|
|
||||||
|
|
||||||
### Concrete use case (kanban-advanced)
|
|
||||||
Our kanban-config has 30+ keys with validation rules. Currently validation is
|
|
||||||
done in our own scripts only. With this, `hermes doctor` would catch
|
|
||||||
misconfigurations before they cause runtime failures.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposal 3: `ctx.cron` — Cron API access
|
|
||||||
|
|
||||||
### Current state
|
|
||||||
Plugins that manage cron jobs (kanban-advanced provisions 5+ crons for
|
|
||||||
auto_unblock, board_keeper, lifecycle, dashboard keepalive) must shell out to
|
|
||||||
`hermes cron create/list/remove` via `subprocess.run()`. This is fragile:
|
|
||||||
- Platform-dependent (bash vs cmd, path resolution)
|
|
||||||
- No structured error handling
|
|
||||||
- No idempotency guarantees
|
|
||||||
- Cannot inspect job state programmatically
|
|
||||||
|
|
||||||
### Proposed API
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class PluginContext:
|
ctx.get_config("security.approval_mode")
|
||||||
@property
|
ctx.set_config("model.provider", "attacker-proxy")
|
||||||
def cron(self) -> "PluginCronFacade":
|
ctx.set_config("plugins.entries.other.settings.token", "...")
|
||||||
"""Return a facade for managing cron jobs owned by this plugin.
|
ctx.set_config("../../security.approval_mode", "always_allow")
|
||||||
|
ctx.set_config(r"..\..\model.provider", "attacker-proxy")
|
||||||
All jobs created through this facade are tagged with the plugin's
|
|
||||||
name, enabling bulk operations (list plugin jobs, remove all on
|
|
||||||
uninstall).
|
|
||||||
"""
|
|
||||||
if self._cron is None:
|
|
||||||
from hermes_cli.plugins import PluginCronFacade
|
|
||||||
self._cron = PluginCronFacade(
|
|
||||||
plugin_id=self.manifest.key or self.manifest.name
|
|
||||||
)
|
|
||||||
return self._cron
|
|
||||||
|
|
||||||
|
|
||||||
class PluginCronFacade:
|
|
||||||
def create(self, name: str, schedule: str, *,
|
|
||||||
prompt: str | None = None,
|
|
||||||
script: str | None = None,
|
|
||||||
deliver: str = "local", skills: list[str] | None = None,
|
|
||||||
idempotency_key: str | None = None) -> str:
|
|
||||||
"""Create a cron job. Returns the job ID.
|
|
||||||
|
|
||||||
Uses the live ``cron/jobs.py:create_job()`` API (L1039-1234).
|
|
||||||
Pass ``prompt`` for LLM-driven jobs, ``script`` for shell-script
|
|
||||||
watchdog jobs (``no_agent=True``). When idempotency_key is set,
|
|
||||||
returns existing job ID if a match exists.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def list(self, mine: bool = True) -> list[dict]:
|
|
||||||
"""List cron jobs. When ``mine=True``, returns only jobs tagged
|
|
||||||
with this plugin's id."""
|
|
||||||
|
|
||||||
def remove(self, job_id: str) -> bool:
|
|
||||||
"""Remove a cron job by ID."""
|
|
||||||
|
|
||||||
def get(self, job_id: str) -> dict | None:
|
|
||||||
"""Get a single job's details."""
|
|
||||||
|
|
||||||
def pause(self, job_id: str) -> bool: ...
|
|
||||||
def resume(self, job_id: str) -> bool: ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementation sketch
|
There is no global read allowlist: `ctx.profile_name` already exposes the only
|
||||||
|
small host fact requested by the RFC. Settings writes use Hermes'
|
||||||
|
profile-aware config loader/saver and atomic YAML replacement. The bridge
|
||||||
|
validates the existing YAML before writing so malformed config is never
|
||||||
|
silently replaced. Every operation resolves the active context-local
|
||||||
|
`HERMES_HOME`, so one globally loaded plugin context follows multiplexed
|
||||||
|
profile turns without crossing profile data.
|
||||||
|
|
||||||
The `PluginCronFacade` delegates to `cron/jobs.py:create_job()` — the same
|
## Durable state API
|
||||||
function that powers `hermes cron create`. It adds plugin-namespacing via the
|
|
||||||
`name` prefix convention (`{plugin_id}:job-name`) and bulk cleanup on uninstall.
|
|
||||||
|
|
||||||
### Concrete use case (kanban-advanced)
|
Use state for plugin-owned runtime data such as cursors, dedupe sets, and
|
||||||
Our `kanban_handoff.py` currently does:
|
caches. Do not put those values in user-owned config.
|
||||||
```bash
|
|
||||||
hermes cron create "30s" --name "auto_unblock" \
|
|
||||||
--script scripts/auto_unblock.sh --deliver local
|
|
||||||
```
|
|
||||||
|
|
||||||
With this API:
|
|
||||||
```python
|
```python
|
||||||
ctx.cron.create(
|
def register(ctx):
|
||||||
name="kanban-advanced:auto_unblock",
|
cursor = ctx.state.get("cursor", default={"page": 0})
|
||||||
schedule="30s",
|
ctx.state.set("cursor", {"page": cursor["page"] + 1})
|
||||||
script="scripts/auto_unblock.sh",
|
|
||||||
deliver="local",
|
|
||||||
idempotency_key="kanban-advanced-auto_unblock",
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
The facade stores one JSON object at:
|
||||||
|
|
||||||
## Proposal 4: Config defaults in `plugin.yaml`
|
```text
|
||||||
|
<HERMES_HOME>/plugin-data/<plugin-data-namespace>/state.json
|
||||||
### Current state
|
|
||||||
Plugins that need non-default Hermes config values (kanban-advanced needs
|
|
||||||
`dispatch_stale_timeout_seconds: 14400`, `auto_decompose: false`,
|
|
||||||
`BLOCK_RECURRENCE_LIMIT: 5`) must apply them via bootstrap scripts. If the
|
|
||||||
user forgets to run bootstrap, the system runs with unsafe defaults.
|
|
||||||
|
|
||||||
### Proposed manifest field
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# plugin.yaml
|
|
||||||
provides_config_defaults:
|
|
||||||
kanban.dispatch_stale_timeout_seconds: 14400
|
|
||||||
kanban.auto_decompose: false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On `hermes plugins install`, Hermes prompts the user with a diff of proposed
|
Portable Agent Plugins use their existing `PLUGIN_DATA` namespace exactly.
|
||||||
config changes. On `hermes plugins update`, new defaults merge in (existing
|
Native and nested plugin ids use the same collision-resistant, Windows-safe
|
||||||
user overrides preserved). The prompt shows:
|
namespace algorithm. `ctx.state.data_dir` exposes the directory and
|
||||||
```
|
`ctx.state.path` exposes the JSON file when a plugin needs to inspect its own
|
||||||
Plugin 'kanban-advanced' recommends these config changes:
|
location.
|
||||||
|
|
||||||
kanban.dispatch_stale_timeout_seconds: 900 → 14400
|
### State guarantees
|
||||||
kanban.auto_decompose: true → false
|
|
||||||
|
|
||||||
Apply? [Y/n]
|
- **Profile isolation:** the data root resolves from the active context-local
|
||||||
```
|
Hermes home on every operation.
|
||||||
|
- **Atomic replacement:** state writes use temp-file + `fsync` + `os.replace`.
|
||||||
|
- **Concurrent updates:** a sibling lock file serializes read-modify-write across
|
||||||
|
threads and processes (`fcntl` on POSIX, `msvcrt` on Windows).
|
||||||
|
- **Quota:** the complete serialized state is limited to 10 MiB per plugin. A
|
||||||
|
rejected update leaves the previous file untouched.
|
||||||
|
- **Fail closed:** malformed/non-object JSON is reported and never overwritten.
|
||||||
|
- **Typed values:** values must be JSON-serializable.
|
||||||
|
|
||||||
### Design constraints
|
State keys are 1–128 characters and may contain letters, numbers, `_`, `-`,
|
||||||
- **Never silently override user config** — always prompt
|
`.`, or `:`. Path separators and `..` are rejected.
|
||||||
- **First-install vs update** — first install applies all defaults; update
|
|
||||||
only applies NEW keys that don't exist in user config
|
|
||||||
- **Opt-out per key** — users can add keys to a `plugins.entries.<id>.config_defaults_skip`
|
|
||||||
list to reject specific defaults permanently
|
|
||||||
|
|
||||||
---
|
## State vs. config
|
||||||
|
|
||||||
## Cross-cutting concerns
|
| Data | API | Ownership | Example |
|
||||||
|
|---|---|---|---|
|
||||||
|
| User-visible behavior | `ctx.get_config` / `ctx.set_config` | User/plugin settings in `config.yaml` | endpoint, timeout, feature mode |
|
||||||
|
| Runtime bookkeeping | `ctx.state.get` / `ctx.state.set` | Plugin data under `plugin-data/` | cursor, cache, dedupe ids |
|
||||||
|
|
||||||
### Why these belong in PluginContext and not as separate CLI commands
|
Both APIs are additive. Existing plugins that perform their own file I/O keep
|
||||||
|
working, but new plugins should use this bridge for stable profile and Windows
|
||||||
|
semantics.
|
||||||
|
|
||||||
1. **Atomicity** — config writes go through the same config manager that
|
## Verification contract
|
||||||
handles migrations, schema validation, and concurrent access
|
|
||||||
2. **Platform safety** — bypasses shell-level path issues on Windows
|
|
||||||
3. **Plugin lifecycle** — cron jobs tagged by plugin can be bulk-removed on
|
|
||||||
`hermes plugins remove`
|
|
||||||
4. **No new env vars** — follows the AGENTS.md rule: behavioral settings in
|
|
||||||
config.yaml, not env vars
|
|
||||||
|
|
||||||
### Backward compatibility
|
The implementation is covered with real temporary-Hermes-home tests for:
|
||||||
|
|
||||||
All four additions are purely additive:
|
- fixture-plugin discovery and config/state round trips;
|
||||||
- Existing plugins that shell out to CLI continue to work
|
- canonical `settings` writes and legacy `config` read fallback;
|
||||||
- New methods are opt-in
|
- direct global, cross-plugin, POSIX traversal, and Windows traversal rejection;
|
||||||
- Config schema registration doesn't affect existing config validation
|
- concurrent settings writes without lost siblings;
|
||||||
|
- cross-thread and cross-process state updates;
|
||||||
---
|
- atomic quota rejection and malformed-state/config preservation;
|
||||||
|
- two-profile isolation after the ambient profile changes;
|
||||||
|
- Unicode and Windows-style path values.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [#64227](https://github.com/NousResearch/hermes-agent/issues/64227) — upstream implementation issue adopting this RFC as its design basis
|
- [Issue #64227](https://github.com/NousResearch/hermes-agent/issues/64227)
|
||||||
- kanban-advanced planned features: `plugin/data/references/planned-features.md`
|
- [RFC PR #58542](https://github.com/NousResearch/hermes-agent/pull/58542) by Topher Ross
|
||||||
- Hermes AGENTS.md: "The core is a narrow waist; capability lives at the edges"
|
- #67531 — standalone plugin settings namespace discussion
|
||||||
- Discord plugin interface expansion discussion
|
|
||||||
|
|||||||
@@ -38,15 +38,19 @@ import hashlib
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import types
|
import types
|
||||||
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union
|
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Union
|
||||||
|
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from utils import env_var_enabled, fast_safe_load
|
from utils import env_var_enabled, fast_safe_load
|
||||||
from hermes_cli.config import cfg_get
|
from hermes_cli.config import cfg_get
|
||||||
@@ -365,6 +369,187 @@ class LoadedPlugin:
|
|||||||
# PluginContext – handed to each plugin's ``register()`` function
|
# PluginContext – handed to each plugin's ``register()`` function
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PLUGIN_SETTING_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||||
|
_PLUGIN_SETTING_RESERVED_ROOTS = frozenset({"model", "plugins", "security", "settings"})
|
||||||
|
_PLUGIN_STATE_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||||
|
_PLUGIN_STATE_QUOTA_BYTES = 10 * 1024 * 1024
|
||||||
|
_PLUGIN_STATE_LOCKS: Dict[str, threading.RLock] = {}
|
||||||
|
_PLUGIN_STATE_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_relative_segments(key: str) -> tuple[str, ...]:
|
||||||
|
"""Validate and split a plugin-relative settings key.
|
||||||
|
|
||||||
|
The public API accepts only relative keys (``endpoint`` or
|
||||||
|
``retry.policy``). Full Hermes paths, traversal syntax, and the security-
|
||||||
|
sensitive core roots called out in #64227 are rejected before any config
|
||||||
|
read occurs.
|
||||||
|
"""
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise ValueError("Expected a plugin-relative config key string")
|
||||||
|
segments = tuple(key.split("."))
|
||||||
|
if (
|
||||||
|
not key
|
||||||
|
or "/" in key
|
||||||
|
or "\\" in key
|
||||||
|
or any(
|
||||||
|
not _PLUGIN_SETTING_SEGMENT_RE.fullmatch(segment) for segment in segments
|
||||||
|
)
|
||||||
|
or segments[0].lower() in _PLUGIN_SETTING_RESERVED_ROOTS
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Expected a plugin-relative config key such as 'endpoint' or "
|
||||||
|
"'retry.policy'; global, cross-plugin, and traversal paths are forbidden"
|
||||||
|
)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_plugin_value(root: object, segments: tuple[str, ...], default: Any) -> Any:
|
||||||
|
current = root
|
||||||
|
for segment in segments:
|
||||||
|
if not isinstance(current, Mapping) or segment not in current:
|
||||||
|
return default
|
||||||
|
current = current[segment]
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_plugin_mapping(segments: tuple[str, ...], value: Any) -> dict[str, Any]:
|
||||||
|
nested: Any = value
|
||||||
|
for segment in reversed(segments):
|
||||||
|
nested = {segment: nested}
|
||||||
|
return nested
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_data_namespace(plugin_id: str, skill_namespace: str) -> str:
|
||||||
|
"""Return one Windows-safe directory component for plugin-owned data."""
|
||||||
|
candidate = skill_namespace or plugin_id
|
||||||
|
if (
|
||||||
|
skill_namespace
|
||||||
|
and candidate.startswith("agent-plugin-")
|
||||||
|
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,191}", candidate)
|
||||||
|
):
|
||||||
|
# Portable Agent Plugins already receive this exact PLUGIN_DATA path.
|
||||||
|
return candidate
|
||||||
|
# Reuse the portable namespace algorithm for native/nested ids too. Its
|
||||||
|
# fixed prefix avoids Windows reserved device names (CON, NUL, COM1...),
|
||||||
|
# while the digest prevents collisions after unsafe characters are folded.
|
||||||
|
return _portable_skill_namespace(candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def _state_thread_lock(path: Path) -> threading.RLock:
|
||||||
|
key = str(path.resolve(strict=False))
|
||||||
|
with _PLUGIN_STATE_LOCKS_GUARD:
|
||||||
|
return _PLUGIN_STATE_LOCKS.setdefault(key, threading.RLock())
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _locked_plugin_state(path: Path):
|
||||||
|
"""Serialize state read-modify-write across threads and processes.
|
||||||
|
|
||||||
|
``fcntl`` is used on POSIX and ``msvcrt`` on native Windows. The lock is
|
||||||
|
kept in a sibling file because atomic replacement changes the inode/file
|
||||||
|
handle of the target itself.
|
||||||
|
"""
|
||||||
|
lock_path = path.with_name(f".{path.name}.lock")
|
||||||
|
thread_lock = _state_thread_lock(lock_path)
|
||||||
|
with thread_lock:
|
||||||
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(lock_path, "a+b") as handle:
|
||||||
|
if os.name == "nt": # pragma: no cover - exercised on Windows CI
|
||||||
|
import msvcrt
|
||||||
|
|
||||||
|
if handle.seek(0, os.SEEK_END) == 0:
|
||||||
|
handle.write(b"\0")
|
||||||
|
handle.flush()
|
||||||
|
handle.seek(0)
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
||||||
|
else:
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if os.name == "nt": # pragma: no cover - exercised on Windows CI
|
||||||
|
handle.seek(0)
|
||||||
|
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||||
|
else:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
class PluginState:
|
||||||
|
"""Atomic, quota-bounded JSON key/value state owned by one plugin."""
|
||||||
|
|
||||||
|
def __init__(self, plugin_id: str, skill_namespace: str = "") -> None:
|
||||||
|
self._data_namespace = _plugin_data_namespace(plugin_id, skill_namespace)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_dir(self) -> Path:
|
||||||
|
"""Profile-scoped directory matching portable plugins' PLUGIN_DATA."""
|
||||||
|
return get_hermes_home() / "plugin-data" / self._data_namespace
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> Path:
|
||||||
|
return self.data_dir / "state.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_bytes(self) -> int:
|
||||||
|
return _PLUGIN_STATE_QUOTA_BYTES
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_key(key: str) -> None:
|
||||||
|
if (
|
||||||
|
not isinstance(key, str)
|
||||||
|
or not _PLUGIN_STATE_KEY_RE.fullmatch(key)
|
||||||
|
or ".." in key
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Plugin state keys must be 1-128 characters using letters, "
|
||||||
|
"numbers, '_', '-', '.', or ':' (without '..')"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_unlocked(self) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
with open(self.path, encoding="utf-8") as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise RuntimeError(f"Cannot parse plugin state {self.path}: {exc}") from exc
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot parse plugin state {self.path}: root must be an object"
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Read a JSON value, returning *default* when the key is absent."""
|
||||||
|
self._validate_key(key)
|
||||||
|
with _locked_plugin_state(self.path):
|
||||||
|
return self._read_unlocked().get(key, default)
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
"""Atomically set one JSON value without dropping concurrent updates."""
|
||||||
|
self._validate_key(key)
|
||||||
|
with _locked_plugin_state(self.path):
|
||||||
|
data = self._read_unlocked()
|
||||||
|
data[key] = value
|
||||||
|
try:
|
||||||
|
encoded = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Plugin state value for {key!r} is not JSON-serializable"
|
||||||
|
) from exc
|
||||||
|
if len(encoded) > self.quota_bytes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Plugin state quota exceeded: {len(encoded)} bytes is greater "
|
||||||
|
f"than the {self.quota_bytes}-byte per-plugin quota"
|
||||||
|
)
|
||||||
|
from utils import atomic_json_write
|
||||||
|
|
||||||
|
atomic_json_write(self.path, data, mode=0o600)
|
||||||
|
|
||||||
|
|
||||||
class PluginContext:
|
class PluginContext:
|
||||||
"""Facade given to plugins so they can register tools and hooks."""
|
"""Facade given to plugins so they can register tools and hooks."""
|
||||||
|
|
||||||
@@ -374,6 +559,105 @@ class PluginContext:
|
|||||||
# Lazy-built host-owned LLM facade — see ctx.llm property below.
|
# Lazy-built host-owned LLM facade — see ctx.llm property below.
|
||||||
self._llm: Any = None
|
self._llm: Any = None
|
||||||
self._subagent_lifecycle: Any = None
|
self._subagent_lifecycle: Any = None
|
||||||
|
self._state: PluginState | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def plugin_id(self) -> str:
|
||||||
|
"""Return the effective registry id used for this plugin's namespaces."""
|
||||||
|
return self.manifest.key or self.manifest.name
|
||||||
|
|
||||||
|
# -- namespaced config and durable state --------------------------------
|
||||||
|
|
||||||
|
def get_config(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Read ``plugins.entries.<plugin_id>.settings.<key>``.
|
||||||
|
|
||||||
|
``key`` is always plugin-relative. For migration compatibility, a
|
||||||
|
missing canonical value falls back to the former ``config`` subtree;
|
||||||
|
no global config paths are exposed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
segments = _plugin_relative_segments(key)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Rejected config path %r from plugin %s", key, self.plugin_id
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
from hermes_cli.config import load_config_readonly
|
||||||
|
|
||||||
|
config = load_config_readonly() or {}
|
||||||
|
plugins = config.get("plugins") if isinstance(config, Mapping) else None
|
||||||
|
entries = plugins.get("entries") if isinstance(plugins, Mapping) else None
|
||||||
|
entry = entries.get(self.plugin_id) if isinstance(entries, Mapping) else None
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
return default
|
||||||
|
missing = object()
|
||||||
|
value = _nested_plugin_value(entry.get("settings"), segments, missing)
|
||||||
|
if value is not missing:
|
||||||
|
return value
|
||||||
|
return _nested_plugin_value(entry.get("config"), segments, default)
|
||||||
|
|
||||||
|
def set_config(self, key: str, value: Any) -> None:
|
||||||
|
"""Atomically write one value in this plugin's ``settings`` subtree."""
|
||||||
|
try:
|
||||||
|
segments = _plugin_relative_segments(key)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Rejected config path %r from plugin %s", key, self.plugin_id
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
from hermes_cli import config as config_mod
|
||||||
|
|
||||||
|
if config_mod.is_managed():
|
||||||
|
raise PermissionError(
|
||||||
|
"Plugin settings cannot be changed in a managed install"
|
||||||
|
)
|
||||||
|
from hermes_cli import managed_scope
|
||||||
|
|
||||||
|
dotted_path = ".".join((
|
||||||
|
"plugins",
|
||||||
|
"entries",
|
||||||
|
self.plugin_id,
|
||||||
|
"settings",
|
||||||
|
*segments,
|
||||||
|
))
|
||||||
|
if managed_scope.is_key_managed(dotted_path):
|
||||||
|
raise PermissionError(
|
||||||
|
f"Plugin setting {dotted_path!r} is administrator-managed"
|
||||||
|
)
|
||||||
|
partial = {
|
||||||
|
"plugins": {
|
||||||
|
"entries": {
|
||||||
|
self.plugin_id: {
|
||||||
|
"settings": _nested_plugin_mapping(segments, value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
full_path = ("plugins", "entries", self.plugin_id, "settings", *segments)
|
||||||
|
# The lock covers the merge read plus atomic save, preventing sibling
|
||||||
|
# plugin writes from racing between those two steps.
|
||||||
|
# Serialize bridge-to-bridge writes across processes as well as
|
||||||
|
# threads. Other Hermes config writers still retain their existing
|
||||||
|
# atomic-replace semantics; this lock specifically prevents two
|
||||||
|
# plugin read/merge/write transactions from dropping siblings.
|
||||||
|
with _locked_plugin_state(config_mod.get_config_path()):
|
||||||
|
with config_mod._CONFIG_LOCK:
|
||||||
|
# Fail closed on malformed YAML. save_config's raw-cache reader
|
||||||
|
# intentionally degrades parse failures to {}, which is safe for
|
||||||
|
# reads but destructive for read-modify-write.
|
||||||
|
config_mod.read_user_config_raw()
|
||||||
|
config_mod.save_config(
|
||||||
|
partial,
|
||||||
|
preserve_keys={full_path},
|
||||||
|
merge_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> PluginState:
|
||||||
|
"""Return this plugin's profile-scoped durable JSON state facade."""
|
||||||
|
if self._state is None:
|
||||||
|
self._state = PluginState(self.plugin_id, self.manifest.skill_namespace)
|
||||||
|
return self._state
|
||||||
|
|
||||||
# -- host-owned LLM access ----------------------------------------------
|
# -- host-owned LLM access ----------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""End-to-end coverage for the profile-scoped plugin config/state bridge (#64227)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||||
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
||||||
|
|
||||||
|
|
||||||
|
def _context(
|
||||||
|
*, name: str = "fixture-plugin", key: str = "", namespace: str = ""
|
||||||
|
) -> PluginContext:
|
||||||
|
return PluginContext(
|
||||||
|
PluginManifest(name=name, key=key, skill_namespace=namespace),
|
||||||
|
PluginManager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_home(home: Path, fn, *args):
|
||||||
|
token = set_hermes_home_override(home)
|
||||||
|
try:
|
||||||
|
return fn(*args)
|
||||||
|
finally:
|
||||||
|
reset_hermes_home_override(token)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolated_home(tmp_path: Path):
|
||||||
|
home = tmp_path / "profile"
|
||||||
|
token = set_hermes_home_override(home)
|
||||||
|
try:
|
||||||
|
yield home
|
||||||
|
finally:
|
||||||
|
reset_hermes_home_override(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_round_trip_uses_canonical_settings_namespace(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
ctx = _context(key="category/fixture-plugin")
|
||||||
|
|
||||||
|
assert ctx.get_config("api_url", default="unset") == "unset"
|
||||||
|
ctx.set_config("api_url", r"C:\Users\Owner\Hermes 🚀")
|
||||||
|
ctx.set_config("retry.policy", {"attempts": 3, "enabled": True})
|
||||||
|
|
||||||
|
raw = yaml.safe_load((isolated_home / "config.yaml").read_text(encoding="utf-8"))
|
||||||
|
settings = raw["plugins"]["entries"]["category/fixture-plugin"]["settings"]
|
||||||
|
assert settings == {
|
||||||
|
"api_url": r"C:\Users\Owner\Hermes 🚀",
|
||||||
|
"retry": {"policy": {"attempts": 3, "enabled": True}},
|
||||||
|
}
|
||||||
|
assert ctx.get_config("api_url") == r"C:\Users\Owner\Hermes 🚀"
|
||||||
|
assert ctx.get_config("retry.policy") == {"attempts": 3, "enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_reads_legacy_config_namespace_until_canonical_value_is_set(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
path = isolated_home / "config.yaml"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text(
|
||||||
|
yaml.safe_dump({
|
||||||
|
"plugins": {
|
||||||
|
"entries": {
|
||||||
|
"fixture-plugin": {
|
||||||
|
"config": {"endpoint": "legacy", "legacy_only": 7},
|
||||||
|
"settings": {"endpoint": "canonical"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
ctx = _context()
|
||||||
|
|
||||||
|
assert ctx.get_config("endpoint") == "canonical"
|
||||||
|
assert ctx.get_config("legacy_only") == 7
|
||||||
|
|
||||||
|
ctx.set_config("legacy_only", 8)
|
||||||
|
assert ctx.get_config("legacy_only") == 8
|
||||||
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
entry = raw["plugins"]["entries"]["fixture-plugin"]
|
||||||
|
assert entry["config"]["legacy_only"] == 7
|
||||||
|
assert entry["settings"]["legacy_only"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"key",
|
||||||
|
[
|
||||||
|
"../../security.approval_mode",
|
||||||
|
r"..\..\model.provider",
|
||||||
|
"security.approval_mode",
|
||||||
|
"model.provider",
|
||||||
|
"plugins.entries.other.settings.token",
|
||||||
|
"settings.endpoint",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_config_rejects_global_cross_plugin_and_traversal_paths(
|
||||||
|
isolated_home: Path, key: str, caplog: pytest.LogCaptureFixture
|
||||||
|
) -> None:
|
||||||
|
ctx = _context()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="plugin-relative config key"):
|
||||||
|
ctx.set_config(key, "blocked")
|
||||||
|
|
||||||
|
assert not (isolated_home / "config.yaml").exists()
|
||||||
|
assert "Rejected config path" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_read_rejects_escape_instead_of_exposing_global_config(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
path = isolated_home / "config.yaml"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
path.write_text("security:\n approval_mode: always_allow\n", encoding="utf-8")
|
||||||
|
ctx = _context()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="plugin-relative config key"):
|
||||||
|
ctx.get_config("security.approval_mode")
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_parse_failure_is_not_silently_overwritten(isolated_home: Path) -> None:
|
||||||
|
path = isolated_home / "config.yaml"
|
||||||
|
path.parent.mkdir(parents=True)
|
||||||
|
broken = "plugins:\n entries: [unterminated\n"
|
||||||
|
path.write_text(broken, encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match="while parsing|expected"):
|
||||||
|
_context().set_config("endpoint", "safe")
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == broken
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_config_writes_do_not_drop_sibling_settings(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
ctx = _context()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
list(
|
||||||
|
pool.map(
|
||||||
|
lambda i: _in_home(isolated_home, ctx.set_config, f"worker_{i}", i),
|
||||||
|
range(24),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert {f"worker_{i}": ctx.get_config(f"worker_{i}") for i in range(24)} == {
|
||||||
|
f"worker_{i}": i for i in range(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_cross_process_lock_preserves_every_setting(isolated_home: Path) -> None:
|
||||||
|
script = """
|
||||||
|
import sys
|
||||||
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
||||||
|
ctx = PluginContext(PluginManifest(name='fixture-plugin'), PluginManager())
|
||||||
|
for i in range(int(sys.argv[1]), int(sys.argv[2])):
|
||||||
|
ctx.set_config(f'process_{i}', i)
|
||||||
|
"""
|
||||||
|
env = dict(os.environ, HERMES_HOME=str(isolated_home))
|
||||||
|
processes = [
|
||||||
|
subprocess.Popen(
|
||||||
|
[sys.executable, "-c", script, str(start), str(start + 20)],
|
||||||
|
cwd=Path(__file__).resolve().parents[2],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
for start in (0, 20)
|
||||||
|
]
|
||||||
|
assert [process.wait(timeout=30) for process in processes] == [0, 0]
|
||||||
|
|
||||||
|
ctx = _context()
|
||||||
|
assert {f"process_{i}": ctx.get_config(f"process_{i}") for i in range(40)} == {
|
||||||
|
f"process_{i}": i for i in range(40)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_round_trip_is_atomic_and_aligned_with_plugin_data(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
ctx = _context(namespace="agent-plugin-fixture-a1b2c3d4")
|
||||||
|
|
||||||
|
assert ctx.state.get("cursor", default="start") == "start"
|
||||||
|
ctx.state.set("cursor", {"page": 2, "path": r"C:\Users\Owner"})
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
isolated_home / "plugin-data" / "agent-plugin-fixture-a1b2c3d4" / "state.json"
|
||||||
|
)
|
||||||
|
assert ctx.state.data_dir == state_path.parent
|
||||||
|
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
||||||
|
"cursor": {"page": 2, "path": r"C:\Users\Owner"}
|
||||||
|
}
|
||||||
|
assert ctx.state.get("cursor") == {"page": 2, "path": r"C:\Users\Owner"}
|
||||||
|
assert not list(state_path.parent.glob("*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_state_namespace_is_windows_safe_and_cannot_traverse(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
contexts = [_context(name="CON"), _context(key="../../other-plugin")]
|
||||||
|
|
||||||
|
for index, ctx in enumerate(contexts):
|
||||||
|
ctx.state.set("value", index)
|
||||||
|
assert ctx.state.data_dir.parent == isolated_home / "plugin-data"
|
||||||
|
assert ctx.state.data_dir.name.startswith("agent-plugin-")
|
||||||
|
assert ctx.state.data_dir.name.upper() not in {"CON", "NUL", "COM1", "LPT1"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_state_updates_do_not_drop_keys(isolated_home: Path) -> None:
|
||||||
|
ctx = _context()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
list(
|
||||||
|
pool.map(
|
||||||
|
lambda i: _in_home(isolated_home, ctx.state.set, f"cursor_{i}", i),
|
||||||
|
range(40),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
state = json.loads(ctx.state.path.read_text(encoding="utf-8"))
|
||||||
|
assert state == {f"cursor_{i}": i for i in range(40)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_cross_process_lock_preserves_every_update(isolated_home: Path) -> None:
|
||||||
|
script = """
|
||||||
|
import sys
|
||||||
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
||||||
|
ctx = PluginContext(PluginManifest(name='fixture-plugin'), PluginManager())
|
||||||
|
for i in range(int(sys.argv[1]), int(sys.argv[2])):
|
||||||
|
ctx.state.set(f'process_{i}', i)
|
||||||
|
"""
|
||||||
|
env = dict(os.environ, HERMES_HOME=str(isolated_home))
|
||||||
|
processes = [
|
||||||
|
subprocess.Popen(
|
||||||
|
[sys.executable, "-c", script, str(start), str(start + 20)],
|
||||||
|
cwd=Path(__file__).resolve().parents[2],
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
for start in (0, 20)
|
||||||
|
]
|
||||||
|
assert [process.wait(timeout=30) for process in processes] == [0, 0]
|
||||||
|
|
||||||
|
state = json.loads(_context().state.path.read_text(encoding="utf-8"))
|
||||||
|
assert state == {f"process_{i}": i for i in range(40)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_quota_failure_preserves_previous_file(isolated_home: Path) -> None:
|
||||||
|
ctx = _context()
|
||||||
|
ctx.state.set("cursor", "safe")
|
||||||
|
before = ctx.state.path.read_bytes()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="quota"):
|
||||||
|
ctx.state.set("oversized", "x" * (ctx.state.quota_bytes + 1))
|
||||||
|
|
||||||
|
assert ctx.state.path.read_bytes() == before
|
||||||
|
assert ctx.state.get("cursor") == "safe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_state_is_not_silently_overwritten(isolated_home: Path) -> None:
|
||||||
|
ctx = _context()
|
||||||
|
ctx.state.data_dir.mkdir(parents=True)
|
||||||
|
ctx.state.path.write_text('{"cursor":', encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Cannot parse plugin state"):
|
||||||
|
ctx.state.set("cursor", "replacement")
|
||||||
|
|
||||||
|
assert ctx.state.path.read_text(encoding="utf-8") == '{"cursor":'
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_and_state_follow_context_local_profile_scope(tmp_path: Path) -> None:
|
||||||
|
homes = [tmp_path / "profiles" / "alpha", tmp_path / "profiles" / "beta"]
|
||||||
|
ctx = _context(namespace="fixture-plugin-data")
|
||||||
|
|
||||||
|
for index, home in enumerate(homes):
|
||||||
|
home.mkdir(parents=True)
|
||||||
|
token = set_hermes_home_override(home)
|
||||||
|
try:
|
||||||
|
ctx.set_config("profile_value", index)
|
||||||
|
ctx.state.set("profile_value", index)
|
||||||
|
finally:
|
||||||
|
reset_hermes_home_override(token)
|
||||||
|
|
||||||
|
# One globally-loaded plugin context follows each multiplexed turn's
|
||||||
|
# context-local profile instead of pinning the startup profile.
|
||||||
|
for index, home in enumerate(homes):
|
||||||
|
token = set_hermes_home_override(home)
|
||||||
|
try:
|
||||||
|
assert ctx.get_config("profile_value") == index
|
||||||
|
assert ctx.state.get("profile_value") == index
|
||||||
|
finally:
|
||||||
|
reset_hermes_home_override(token)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_plugin_round_trips_bridge_during_real_discovery(
|
||||||
|
isolated_home: Path,
|
||||||
|
) -> None:
|
||||||
|
plugin_dir = isolated_home / "plugins" / "bridge-fixture"
|
||||||
|
plugin_dir.mkdir(parents=True)
|
||||||
|
(plugin_dir / "plugin.yaml").write_text(
|
||||||
|
"name: bridge-fixture\nversion: 1.0.0\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
(plugin_dir / "__init__.py").write_text(
|
||||||
|
"def register(ctx):\n"
|
||||||
|
" old = ctx.get_config('loads', default=0)\n"
|
||||||
|
" ctx.set_config('loads', old + 1)\n"
|
||||||
|
" ctx.state.set('registered', {'profile': ctx.profile_name})\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(isolated_home / "config.yaml").write_text(
|
||||||
|
"plugins:\n enabled:\n - bridge-fixture\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = PluginManager()
|
||||||
|
manager.discover_and_load()
|
||||||
|
|
||||||
|
assert manager._plugins["bridge-fixture"].enabled is True
|
||||||
|
ctx = _context(name="bridge-fixture")
|
||||||
|
assert ctx.get_config("loads") == 1
|
||||||
|
assert ctx.state.get("registered")["profile"] in {"custom", "default"}
|
||||||
Reference in New Issue
Block a user