Skip to content

feat(core): add extension protocols for credentials, state, knowledge, and throttle backends#125

Merged
hyoshi merged 12 commits into
mainfrom
feature/core-extension-protocols
May 21, 2026
Merged

feat(core): add extension protocols for credentials, state, knowledge, and throttle backends#125
hyoshi merged 12 commits into
mainfrom
feature/core-extension-protocols

Conversation

@hyoshi
Copy link
Copy Markdown
Collaborator

@hyoshi hyoshi commented May 21, 2026

Summary

Add mureo.core.{SecretStore, StateStore, KnowledgeStore, ThrottleStore, RuntimeContext} Protocols, file-backed default implementations, and a default_runtime_context() factory. Three additive commits, zero existing-file behavioural change.

Why

Today's call sites in mureo.auth, mureo.context, mureo.mcp, and skills/learn/SKILL.md hard-wire file paths and module-level singletons. That makes:

  • Unit tests harder than necessary (filesystem mocking, env vars, __file__ monkey-patching).
  • Alternate credential backends (OS keychain, HashiCorp Vault, GCP/AWS Secret Manager) impossible to inject without forking.
  • BYOD's MUREO_BYOD_DIR env-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/ and mureo/core/skills/.

What's in this PR

Commit Content
c9713d2 Five Protocols + RuntimeContext dataclass (types only)
3d57367 FilesystemSecretStore, FilesystemStateStore, FilesystemKnowledgeStore, ProcessLocalThrottleStore
16b58ae default_runtime_context() factory + mureo.core public surface (__all__)

Public surface added to mureo.core

from mureo.core import (
    SecretStore, StateStore, KnowledgeStore, ThrottleStore,   # Protocols
    FilesystemSecretStore, FilesystemStateStore,
    FilesystemKnowledgeStore, ProcessLocalThrottleStore,      # file-backed defaults
    RuntimeContext, DEFAULT_WORKSPACE_ID,                     # aggregate + sentinel
    default_runtime_context,                                  # factory
)

Compatibility

  • No existing module imports the new types yet. Every existing call site continues to use legacy helpers (auth.load_credentials, read_state_file, etc.) directly. This PR is purely additive.
  • mureo/core/__init__.py was empty; now exports 11 names. No prior re-exports to break.
  • The default implementations wrap legacy helpers / file formats so on-disk artefacts are byte-equivalent to what callers see today.

Bi-directional file-format equivalence verified

An end-to-end smoke test confirmed:

  • mureo.auth.load_credentials(path) reads the file written by FilesystemSecretStore.
  • mureo.context.state.read_state_file(path) reads the file written by FilesystemStateStore.
  • Same for the reverse direction (defaults read what legacy wrote).

Test plan

  • pytest tests/core/ — 382 tests pass (+83 new structural & file-IO tests)
  • ruff check mureo/ — clean
  • black --check mureo/ — clean
  • Windows CI lane (added in fix(windows): stop os.fchmod crash, correct Desktop config path, add Windows CI #122) — Windows-safe Path.home patching applied to three tests that previously used monkeypatch.setenv("HOME", ...)
  • No regression in related existing tests: tests/test_auth.py, test_state.py, test_strategy.py, test_throttle.py, test_fsutil.py, test_cli_providers.py (136 tests, verified locally)

hyoshi added 12 commits May 21, 2026 12:52
…, 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 hyoshi merged commit 4741f56 into main May 21, 2026
9 checks passed
@hyoshi hyoshi deleted the feature/core-extension-protocols branch May 21, 2026 06:51
@hyoshi hyoshi mentioned this pull request May 21, 2026
3 tasks
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant