feat(core): add extension protocols for credentials, state, knowledge, and throttle backends#125
Merged
Merged
Conversation
…, and throttle backends Introduce five new Protocol/dataclass types under mureo.core so call sites can later accept pluggable backends (in-memory for tests, alternate stores such as keychains, SQLite, Redis, or hosted file APIs) without touching the legacy file-based defaults. This commit is purely additive — no existing module imports the new types yet, so behaviour is unchanged for every current caller. Follow-up commits will wire concrete default implementations and refactor the existing helpers in mureo.auth, mureo.context, and mureo.mcp to consume the Protocols. * SecretStore — credential round-trip with idempotent delete * StateStore — STATE.json / STRATEGY.md / action-log persistence * KnowledgeStore — two-tier /learn knowledge with operator + workspace * ThrottleStore — async per-key rate-limit gate matching today's mureo.throttle.Throttler shape * RuntimeContext — frozen aggregate, validates non-empty workspace_id Tests: 27 new structural-contract tests in tests/core/; all 335 tests under tests/core/ pass; no existing files modified.
…r extension protocols Add the in-process default implementations of the Protocols introduced in c9713d2: * FilesystemSecretStore — JSON file under ~/.mureo/credentials.json; atomic write via tempfile + os.replace, secure_fchmod for 0o600 on POSIX, ensure_ascii=False to match the legacy file format * FilesystemStateStore — composes the existing helpers in mureo.context.state and mureo.context.strategy so behaviour matches today's CWD-relative call sites verbatim * FilesystemKnowledgeStore — Markdown files under ~/.claude/skills/_mureo-pro-diagnosis/; seeds the same frontmatter scaffold that skills/learn/SKILL.md uses today (drift caught by a regression test) * ProcessLocalThrottleStore — per-key dict of mureo.throttle.Throttler; register(key, config) pre-installs custom buckets matching the MCP server's _PLUGIN_TOOL_THROTTLERS pattern Still purely additive. No existing module imports the new classes yet; behaviour is unchanged for every current caller. The follow-up commit will add the RuntimeContext default factory and re-export the public surface from mureo.core. Tests: 37 new tests in tests/core/ (35 implementation + scaffold drift guard + register custom config); all 372 tests/core/ tests pass; 124 related existing tests (auth/state/strategy/throttle/mcp_context/ fsutil) still pass.
…face * mureo.core.runtime_context.default_runtime_context() wires the four file-backed defaults from c9713d2/3d57367 into a single RuntimeContext; every parameter is keyword-only and optional, falling back to the legacy file locations callers already see today. * DEFAULT_WORKSPACE_ID = "default" promoted to a module-level constant and exported so consumers compare against a named sentinel rather than an inline literal. * mureo.core.__init__.py grows from empty to a curated public surface: 10 Protocols / defaults / RuntimeContext / factory / sentinel, locked by __all__ and a contract test that pins both the positive surface and the negative (providers/skills sub-packages are NOT re-exported). Lint cleanup bundled in: TYPE_CHECKING-only imports for Path, ThrottleConfig, and the three context.models dataclasses; black formatting for runtime_context.py and knowledge_store.py; HOME-env monkeypatching replaced with direct Path.home patching across three tests so the new Windows CI lane (#122) stays green. Tests: 9 new tests (5 factory + 4 public-API); all 382 tests/core pass; related existing tests (auth/state/strategy/throttle/fsutil/cli_providers = 136) still pass; ruff and black clean on mureo/.
…tories Introduce mureo.core.runtime_context.get_runtime_context() — the integration point alternate backends use to inject a custom RuntimeContext without changing call sites. A third-party distribution registers a zero-arg callable under the "mureo.runtime_context_factory" entry-point group; the resolver picks it up automatically. Resolution rules: * 0 entry points: returns default_runtime_context() (today's behaviour for every existing caller). * 1 entry point: loads and calls it; the returned RuntimeContext is cached for the process lifetime. * >1 entry points: raises RuntimeContextFactoryError. Unlike mureo.core.providers.registry (additive, first-wins+warning), the RuntimeContext is a process singleton — silently picking one would hide a packaging bug. Only successfully-constructed contexts are cached; a broken plugin re-runs ep.load() per call so a fix-in-place is visible without a process restart. Public additions to mureo.core: RUNTIME_CONTEXT_FACTORY_ENTRY_POINT_GROUP, RuntimeContextFactoryError, get_runtime_context, reset_runtime_context. Still purely additive: no consumer calls get_runtime_context() yet. Wiring of mureo.auth / mureo.context / mureo.mcp follows in subsequent commits. Tests: 9 new tests in tests/core/test_runtime_resolver.py covering 0/1/multi entry points, cache hit/reset, type validation, factory exception wrapping, and public-API export. 391 tests/core total pass; ruff and black clean on mureo/.
…ocol `load_google_ads_credentials` and `load_meta_ads_credentials` now read their credential dict from a `SecretStore` instead of calling the file-direct `load_credentials` helper. Resolution rules: * `path=` given → one-shot `FilesystemSecretStore(path=path)`. Bypasses the process-wide `RuntimeContext` so the long-standing per-test credentials file pattern (`tests/test_auth.py`) keeps working unchanged and stays isolated from any installed alternate backend. * `path=None` → `get_runtime_context().secret_store`. In OSS today that is the file-backed default reading `~/.mureo/credentials.json` — byte-for-byte identical to the prior behaviour for every existing caller. When an alternate backend registers a `mureo.runtime_context_factory` entry point (added in c96e99b), the credential read transparently routes through it. `load_credentials` is intentionally kept unchanged: it is part of the public surface and may have out-of-tree callers; in-repo callers are limited to `tests/test_auth.py` and the two helpers above. Removing it would be a separate deprecation. Behaviour change worth calling out: the prior code distinguished "section missing" (returns `None` → falls to env) from "section present but invalid". The Protocol contract returns `{}` for both, so the helpers now also skip empty dicts (`isinstance(..., dict) and section`) to preserve the env-var fallback path. Verified by the existing env-fallback tests in `tests/test_auth.py`. Tests: 4 new tests in `tests/test_auth_runtime_context.py` covering runtime-context routing, explicit-path bypass, and env-fallback when the resolved store is empty. All 26 existing `tests/test_auth.py` tests pass without modification; `tests/core/` 391 tests pass; ruff and black clean on `mureo/`. Test-isolation note: the new test file patches `_cached_context` directly via `monkeypatch.setattr` and uses an autouse fixture to `reset_runtime_context()` before and after each test, so the resolver cache cannot bleed state into other test files.
The five `mureo_state_*` / `mureo_strategy_*` MCP handlers now resolve paths against the active `StateStore`'s workspace rather than the raw process CWD. With the default file-backed runtime the two are equal, so behaviour is unchanged for every existing caller. When an alternate backend registers via the `mureo.runtime_context_factory` entry-point group (added in c96e99b), the handlers read and write under that backend's workspace transparently. What changed in `mureo/mcp/_handlers_mureo_context.py`: * `_resolve_path` now takes an optional `store_attr` ("state_path" or "strategy_path"). When the `path` argument is missing or empty, it returns the backend-owned path if exposed, otherwise `workspace / default_name`. When `path` is supplied, it resolves relative to the workspace (not raw CWD) and re-runs the symlink-following boundary check against that workspace. * The error message becomes "Refusing to read/write outside workspace" (was "outside cwd") so the security boundary is correctly named. * Removed the now-unused `_opt` import; added `get_runtime_context` import. Empty-string `path` argument behaviour: the old `_opt`-based code dispatched empty strings to `Path(".")` which silently meant CWD. The new code treats `None` and `""` identically as "no override", which is safer and matches what callers actually want. Test infrastructure: * `tests/test_mcp_tools_mureo_context.py` gains an autouse fixture `_clear_runtime_context_cache` that calls `reset_runtime_context()` before and after every test, so the per-test `monkeypatch.chdir` is observed by `_resolve_path` instead of a stale `FilesystemStateStore` cached by an earlier test. * `test_path_argument_refuses_traversal` is updated to match the new error message and includes a docstring explaining why the workspace equals CWD in the default configuration. * New `test_default_path_follows_runtime_context_workspace` injects a workspace different from CWD via `_cached_context` and asserts the action-log append lands in the injected workspace (and NOT in CWD). Tests: 14 in `tests/test_mcp_tools_mureo_context.py` pass (13 existing + 1 new); `tests/core/` 391 pass; `tests/test_auth.py` + `tests/test_auth_runtime_context.py` 30 pass. ruff and black clean on `mureo/`.
`mureo/mcp/_handlers_rollback.py`'s `_resolve_state_file` now resolves the user-supplied `state_file` argument against the active StateStore's workspace rather than the raw process CWD. Mirrors the refactor applied to `_handlers_mureo_context.py` in 4f5fcd7. With the default file-backed runtime the two are equal, so behaviour is unchanged for every existing caller. When an alternate backend registers via the `mureo.runtime_context_factory` entry-point group (added in c96e99b), the rollback surface reads STATE.json under the backend's workspace transparently. Differences from the mureo_context refactor: * Argument name is `state_file` (not `path`) — kept for API stability. * The state-path attribute is hardcoded to `state_path` (not parameterised through `store_attr`) because rollback only reads STATE.json, never STRATEGY.md. * The error message becomes "inside the active workspace" (was "inside the current working directory"). * Backend-owned `state_path` is `.resolve()`d so a relative path from a custom backend cannot trip `execute_rollback`'s CWD-relative interpretation downstream. Test infrastructure: `tests/test_mcp_tools_rollback.py` gains the same autouse `_clear_runtime_context_cache` fixture introduced for mureo_context (`4f5fcd7`); the existing `sandboxed_cwd` fixture is re-documented to note the autouse runs first. `test_path_traversal_refused` asserts the updated error message and gains a docstring explaining why workspace == CWD in the default configuration. Tests: 9 in `tests/test_mcp_tools_rollback.py` pass; 23 in `tests/test_mcp_tools_rollback.py` + `tests/test_mcp_tools_mureo_context.py` combined pass (no cache cross-contamination). 440 tests across `tests/core/`, `tests/test_auth*.py`, and the two refactored handler files pass; ruff and black clean on `mureo/`.
…ateStore workspace Two more consumers of STATE.json path resolution now honour the active StateStore's workspace rather than the raw process CWD. With the default file-backed runtime the two are equal, so behaviour is unchanged for every existing caller. An alternate backend registered via the `mureo.runtime_context_factory` entry-point group (added in c96e99b) redirects both surfaces transparently. mureo/mcp/_handlers_analysis.py * `_resolve_state_file` mirrors the workspace-based pattern applied to the rollback handler in 931d93a, with one important addition: the long-standing symlink refusal is preserved (a symlink anywhere in the path chain inside the workspace is rejected because the analysis surface returns derived metrics that an attacker could influence by swapping the symlink target mid-call). * Error message becomes "inside the active workspace" (was "inside the current working directory"); module docstring updated to match. * Backend-owned `state_path` is `.resolve()`d so a relative path from a custom backend cannot trip downstream callers. mureo/cli/rollback_cmd.py * `--state-file` Typer option default flips from `Path("STATE.json")` (CWD-relative at command-invocation time) to `None`. A new helper `_resolve_default_state_file()` resolves the missing value at command time through `get_runtime_context().state_store`. * `rollback_list` and `rollback_show` accept `Path | None` and resolve the default at the top of the body. Callers that pass `--state-file` explicitly see no behaviour change. Test infrastructure: * `tests/test_mcp_tools_analysis.py` gains the autouse cache-reset fixture and asserts the updated workspace-aware error message. * `tests/test_cli_rollback.py` gains the autouse cache-reset fixture and a new `TestStateFileDefault` class with two cases that cover the workspace-default path: - `test_default_resolves_to_cwd_workspace`: omitted `--state-file` + CWD STATE.json → entry found - `test_default_resolves_via_injected_runtime_context`: workspace ≠ CWD via `_cached_context` injection → CLI reads workspace STATE.json, not CWD STATE.json * Stale module docstrings in handler and test fixed to reference workspace, not CWD. Tests: 464 pass across `tests/test_cli_rollback.py` (14), `tests/test_mcp_tools_analysis.py` (13), `tests/test_mcp_tools_rollback.py` (9), `tests/test_mcp_tools_mureo_context.py` (14), `tests/test_auth*` (30), and `tests/core/` (391). ruff and black clean on `mureo/`.
The plugin-dispatch branch in `handle_call_tool` now acquires its throttle slot via `get_runtime_context().throttle_store` instead of the module-level `_PLUGIN_TOOL_THROTTLERS` / `_PLUGIN_THROTTLER` attributes directly. An alternate ThrottleStore backend registered via `mureo.runtime_context_factory` can intercept every plugin call without each handler having to change. Default file-backed runtime: byte-equivalent semantics. * New helper `_acquire_plugin_throttle(name)` performs a lazy, idempotent seeding step on the resolved `ProcessLocalThrottleStore`: the module-level per-tool `Throttler` instances from `_PLUGIN_TOOL_THROTTLERS` are copied (by reference) into `store.throttlers`, and `_PLUGIN_THROTTLER` is installed under the sentinel `_PLUGIN_DEFAULT_BUCKET`. Known names go through their own bucket via `store.acquire(name)`; unknown names go through the shared default bucket directly. * The legacy module attributes `_PLUGIN_THROTTLER` and `_PLUGIN_TOOL_THROTTLERS` are kept verbatim because (a) plugin semantics derivation at module load still populates them and (b) existing tests monkey-patch them to inject spies; the seeding helper reuses those exact instances so the contract is unchanged. Alternate backends (anything other than `ProcessLocalThrottleStore`): the seeding step is skipped and `store.acquire(name)` is called once. The backend owns the full per-key + unknown-name fallback semantics; this is documented explicitly in the helper docstring so backend authors know to implement their own shared-default bucket if they want one. Tests: * `tests/test_mcp_server_plugin_wiring.py::test_throttle_acquired_before_dispatch` now patches `_PLUGIN_THROTTLER`, clears the seeding cache (`_throttle_store_seeded`), and resets the RuntimeContext resolver so the next dispatch re-seeds with the spy throttler. The contract remains: "the plugin dispatch path must await a throttle slot before calling the provider". * `test_declared_throttle_gets_dedicated_bucket` is unchanged — it asserts the module-level `_PLUGIN_TOOL_THROTTLERS` still contains a dedicated bucket per declared throttle, which is true because that dict is still populated at module load. 12 tests in `tests/test_mcp_server_plugin_wiring.py` pass; 484 tests across `tests/core/`, the four refactored handler test files, `tests/test_auth*`, and `tests/test_throttle.py` pass. ruff and black clean on `mureo/`.
`byod_data_dir()` gains a middle-priority resolution path: when the resolved `RuntimeContext` exposes a non-default workspace (alternate backend registered via the `mureo.runtime_context_factory` entry-point group), BYOD data is read from `<workspace>/byod/` instead of the legacy `~/.mureo/byod/`. Resolution precedence: 1. `MUREO_BYOD_DIR` env var — unchanged, highest priority (install- desktop wrapper still sets this per-workspace). 2. Non-default `RuntimeContext.state_store.workspace` — NEW. 3. Legacy `~/.mureo/byod/` — preserved for existing CLI / Claude Code single-workspace users. The middle path triggers only when `ctx.workspace_id != "default"` so existing OSS users see no behaviour change. `workspace_id` is the canonical sentinel pinned by `tests/core/test_runtime_context.py`. The helper `_runtime_context_workspace()` is added next to `byod_data_dir()` with a lazy import of `mureo.core.runtime_context` so the module keeps its "no external dependencies" promise (the module docstring already states this so the MCP server's startup path stays light). An ImportError is treated as "no runtime layer" defensively, although the lazy import is expected to succeed in practice. Tests: 489 pass across `tests/test_byod*.py` (49), `tests/core/` (391), and `tests/test_auth*.py` (49). ruff and black clean on `mureo/`.
Introduce `mureo learn add <text> [--scope {operator,workspace}]`, a
new CLI command that persists diagnostic insights via
`get_runtime_context().knowledge_store` instead of writing files
directly. The `/learn` skill is updated to shell out to this command
so the file-system layout (and the scaffold seeding) becomes an
implementation detail of `FilesystemKnowledgeStore` rather than a
duplicated piece of skill markdown.
New CLI: `mureo/cli/learn_cmd.py`
* `learn add <text>` appends to the operator (cross-workspace) tier
by default — the existing pro-diagnosis location for the default
file-backed runtime.
* `--scope workspace` appends to the workspace tier; when the
resolved KnowledgeStore has no workspace tier (the OSS default),
the command exits non-zero with a helpful hint pointing at
`--scope operator`. This is the integration point alternate
backends use to split per-workspace insights from operator-wide
ones.
* Wired into the top-level `mureo` app via `mureo/cli/main.py`.
Skill update: `skills/learn/SKILL.md`
* Version bumped 0.7.1 → 0.8.0.
* Step 2 (manual file-existence-check + scaffold seeding) is gone —
the CLI's KnowledgeStore handles both transparently.
* Step 4 now instructs Claude to invoke `mureo learn add` instead of
using Write/Edit on the file directly.
* IMPORTANT note rewritten: "Always save through `mureo learn add`,
never by writing the file path manually."
Test changes:
* New `tests/test_cli_learn.py` (5 tests): default-scope writes to
operator tier, explicit operator scope, workspace scope when the
backend supports it, workspace scope without backend support exits
with `--scope operator` hint, and the subcommand is registered
under the top-level CLI.
* `tests/core/test_filesystem_knowledge_store.py::test_scaffold_matches_skills_learn_template`
is replaced by `test_scaffold_has_expected_frontmatter_and_section`.
The previous drift-vs-skill regression test no longer applies
because the skill markdown no longer carries its own copy of the
scaffold; the new test instead pins the shape of `_OPERATOR_SCAFFOLD`
directly (frontmatter starts, expected name, ends with the
Learned Insights heading + newline).
407 tests pass across `tests/core/`, `tests/test_cli_learn.py`, and
`tests/test_cli_rollback.py`. ruff and black clean on `mureo/`.
The canonical skills/learn/SKILL.md was updated in c51b84f to route through 'mureo learn add' (KnowledgeStore Protocol), but the packaged mureo/_data/skills/learn/SKILL.md was not synced. The test_packaged_skills_match_canonical_byte_for_byte invariant requires both to be byte-identical so PyPI users see the same docs as the GitHub canonical source. Tests: tests/test_plugin_manifests.py + tests/test_setup_cmd.py + tests/test_cli_learn.py + tests/core/ — 414 pass, ruff and black clean on mureo/.
hyoshi
added a commit
that referenced
this pull request
May 21, 2026
…#126) See CHANGELOG.md for details. Public surface added to mureo.core: * Protocols: SecretStore, StateStore, KnowledgeStore, ThrottleStore * Defaults: FilesystemSecretStore, FilesystemStateStore, FilesystemKnowledgeStore, ProcessLocalThrottleStore * RuntimeContext aggregate + DEFAULT_WORKSPACE_ID sentinel * default_runtime_context() factory + get_runtime_context() resolver * RuntimeContextFactoryError + RUNTIME_CONTEXT_FACTORY_ENTRY_POINT_GROUP New CLI: `mureo learn add <text> [--scope {operator,workspace}]` routes /learn through the KnowledgeStore Protocol. Refactored consumers (call-site changes only, byte-equivalent default behaviour): auth, mureo_context handlers, rollback handlers + CLI, analysis handler, MCP plugin dispatch throttle, BYOD data dir. Version bumped to 0.9.4 across .claude-plugin/plugin.json, mureo/__init__.py, and pyproject.toml.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Add
mureo.core.{SecretStore, StateStore, KnowledgeStore, ThrottleStore, RuntimeContext}Protocols, file-backed default implementations, and adefault_runtime_context()factory. Three additive commits, zero existing-file behavioural change.Why
Today's call sites in
mureo.auth,mureo.context,mureo.mcp, andskills/learn/SKILL.mdhard-wire file paths and module-level singletons. That makes:__file__monkey-patching).MUREO_BYOD_DIRenv-var coupling difficult to vary per call.Introducing thin Protocols + file-backed defaults lets future consumers accept injection without changing today's single-workspace experience. The Protocol shape mirrors the existing pattern in
mureo/core/providers/andmureo/core/skills/.What's in this PR
c9713d2RuntimeContextdataclass (types only)3d57367FilesystemSecretStore,FilesystemStateStore,FilesystemKnowledgeStore,ProcessLocalThrottleStore16b58aedefault_runtime_context()factory +mureo.corepublic surface (__all__)Public surface added to
mureo.coreCompatibility
auth.load_credentials,read_state_file, etc.) directly. This PR is purely additive.mureo/core/__init__.pywas empty; now exports 11 names. No prior re-exports to break.Bi-directional file-format equivalence verified
An end-to-end smoke test confirmed:
mureo.auth.load_credentials(path)reads the file written byFilesystemSecretStore.mureo.context.state.read_state_file(path)reads the file written byFilesystemStateStore.Test plan
pytest tests/core/— 382 tests pass (+83 new structural & file-IO tests)ruff check mureo/— cleanblack --check mureo/— cleanPath.homepatching applied to three tests that previously usedmonkeypatch.setenv("HOME", ...)tests/test_auth.py,test_state.py,test_strategy.py,test_throttle.py,test_fsutil.py,test_cli_providers.py(136 tests, verified locally)