lean-memory-console 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """lean-memory-console — agent-first read-only verification console."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,142 @@
1
+ """FastAPI factory for the console (both modes).
2
+
3
+ Local mode: 127.0.0.1 bind, per-launch session token (query param or
4
+ X-Console-Token header). Docker mode: Authorization: Bearer <api_key>.
5
+ Referrer-Policy: no-referrer on everything; local mode also validates the
6
+ Host header (DNS-rebinding belt-and-suspenders).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+ from contextlib import AsyncExitStack, asynccontextmanager
13
+ from pathlib import Path
14
+
15
+ from fastapi import Depends, FastAPI, HTTPException, Request
16
+ from fastapi.responses import JSONResponse, RedirectResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+
19
+ from .config import ConsoleConfig
20
+ from .engine import EngineGateway
21
+ from .events import EventLog
22
+ from .routes.data import build_data_router
23
+ from .routes.mcp import build_mcp_mount
24
+ from .routes.review import build_review_router
25
+ from .routes.views import build_views_router
26
+
27
+ # Loopback hostnames accepted in local mode (DNS-rebinding guard). Exact-match
28
+ # only: a startswith check would wrongly admit e.g. "localhost.attacker.com".
29
+ _ALLOWED_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost"})
30
+
31
+
32
+ def _static_dir() -> Path:
33
+ """Directory of the built SPA (populated by the UI build). A seam so the
34
+ static-mount-ordering regression can be tested hermetically regardless of
35
+ whether the UI build has materialized ``static/`` on disk."""
36
+ return Path(__file__).parent / "static"
37
+
38
+
39
+ def _ct_eq(presented: str | None, expected: str | None) -> bool:
40
+ """Constant-time string equality. False unless BOTH sides are non-None
41
+ strings (compare_digest raises on None/mismatched types), so a missing
42
+ credential or unconfigured secret never authenticates."""
43
+ if not isinstance(presented, str) or not isinstance(expected, str):
44
+ return False
45
+ return secrets.compare_digest(presented, expected)
46
+
47
+
48
+ def _is_authenticated(request: Request, config: ConsoleConfig) -> bool:
49
+ if config.mode == "docker":
50
+ header = request.headers.get("Authorization", "")
51
+ expected = f"Bearer {config.api_key}" if config.api_key else None
52
+ return _ct_eq(header, expected)
53
+ # local mode: query token OR X-Console-Token header
54
+ token = request.query_params.get("token") or request.headers.get(
55
+ "X-Console-Token"
56
+ )
57
+ return _ct_eq(token, config.session_token)
58
+
59
+
60
+ def require_auth(request: Request) -> None:
61
+ """FastAPI dependency: 401 unless valid credential is presented."""
62
+ config: ConsoleConfig = request.app.state.config
63
+ if not _is_authenticated(request, config):
64
+ raise HTTPException(status_code=401, detail="unauthorized")
65
+
66
+
67
+ def create_app(
68
+ config: ConsoleConfig,
69
+ gateway: EngineGateway,
70
+ event_log: EventLog,
71
+ ) -> FastAPI:
72
+ # In Docker mode, build the streamable-HTTP MCP mount up front so its
73
+ # once-only session manager can be started by the app lifespan below — the
74
+ # only supported start path (run() cannot be re-entered per request). Under a
75
+ # real ASGI server (uvicorn) the lifespan runs; tests hitting /mcp must use a
76
+ # context-managed TestClient so the lifespan runs there too.
77
+ mcp_mount = (
78
+ build_mcp_mount(gateway, config) if config.mode == "docker" else None
79
+ )
80
+
81
+ @asynccontextmanager
82
+ async def _lifespan(_app: FastAPI):
83
+ async with AsyncExitStack() as stack:
84
+ if mcp_mount is not None:
85
+ await stack.enter_async_context(mcp_mount.session_manager.run())
86
+ yield
87
+
88
+ app = FastAPI(title="lean-memory-console", lifespan=_lifespan)
89
+ app.state.config = config
90
+ app.state.gateway = gateway
91
+ app.state.event_log = event_log
92
+
93
+ @app.middleware("http")
94
+ async def _security(request: Request, call_next):
95
+ # Local-mode Host guard (DNS-rebinding belt-and-suspenders).
96
+ if config.mode == "local":
97
+ host = request.headers.get("host", "")
98
+ hostname = host.split(":")[0]
99
+ if hostname not in _ALLOWED_LOCAL_HOSTS:
100
+ resp = JSONResponse(
101
+ {"detail": "forbidden host"}, status_code=403
102
+ )
103
+ resp.headers["Referrer-Policy"] = "no-referrer"
104
+ return resp
105
+ response = await call_next(request)
106
+ response.headers["Referrer-Policy"] = "no-referrer"
107
+ return response
108
+
109
+ app.include_router(build_views_router())
110
+ # /views/*/review + /views/*/maintenance — per-route auth-gated, same prefix
111
+ # as views; registered before the static catch-all so its greedy "/" mount
112
+ # never shadows these paths.
113
+ app.include_router(build_review_router())
114
+ # /v1/* are POST handlers reading app.state.gateway; gate the whole router
115
+ # with the same auth dependency (local token / docker bearer).
116
+ app.include_router(
117
+ build_data_router(), dependencies=[Depends(require_auth)]
118
+ )
119
+
120
+ if mcp_mount is not None:
121
+ # A Starlette Mount("/mcp") only matches "/mcp/..." — a bare "/mcp"
122
+ # (what real MCP clients POST to, per the connect snippet) is NOT matched
123
+ # by the mount. Without a static "/" mount, Starlette's slash-redirect
124
+ # fallback used to bounce "/mcp" -> "/mcp/". But the greedy StaticFiles
125
+ # mount at "/" matches "/mcp" first and 405s every non-GET method before
126
+ # that fallback can run. Register an explicit 307 (method+body preserving)
127
+ # from "/mcp" to "/mcp/" so a bare POST reaches the bearer gate whether or
128
+ # not the SPA is present.
129
+ @app.post("/mcp", include_in_schema=False)
130
+ async def _mcp_slash_redirect() -> RedirectResponse:
131
+ return RedirectResponse(url="/mcp/", status_code=307)
132
+
133
+ app.mount("/mcp", mcp_mount)
134
+
135
+ # The SPA catch-all MUST be registered LAST so its greedy "/" mount never
136
+ # shadows /v1, /views, or /mcp (StaticFiles serves only GET/HEAD and 405s
137
+ # every other method on any path it matches).
138
+ static_dir = _static_dir()
139
+ if static_dir.is_dir():
140
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="spa")
141
+
142
+ return app
@@ -0,0 +1,134 @@
1
+ """`lean-memory-console` CLI: serve | mcp, plus --print-compose-path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.resources
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .config import ConsoleConfig, load_config, resolve_data_root
12
+ from .observe_mcp import run_stdio
13
+
14
+
15
+ def _compose_path() -> Path:
16
+ """Path to the packaged docker-compose.yml.
17
+
18
+ Primary: importlib.resources over the installed package's deploy/ dir
19
+ (the wheel force-includes deploy/docker-compose.yml there). Dev fallback:
20
+ resolve the repo's deploy/docker-compose.yml relative to this file when
21
+ the packaged resource is missing (editable installs may not map the
22
+ force-include).
23
+ """
24
+ try:
25
+ res = importlib.resources.files("lean_memory_console").joinpath(
26
+ "deploy/docker-compose.yml"
27
+ )
28
+ if res.is_file():
29
+ return Path(str(res))
30
+ except (ModuleNotFoundError, FileNotFoundError):
31
+ pass
32
+ # Dev fallback: repo_root/deploy/docker-compose.yml.
33
+ # cli.py -> lean_memory_console -> src -> console -> repo_root
34
+ repo_root = Path(__file__).resolve().parents[3]
35
+ return repo_root / "deploy" / "docker-compose.yml"
36
+
37
+
38
+ def _validate_serve_root(root: Path) -> None:
39
+ if not root.exists() or not os.access(root, os.R_OK):
40
+ sys.stderr.write(
41
+ f"error: data root not readable: {root}\n"
42
+ )
43
+ raise SystemExit(2)
44
+
45
+
46
+ def _run_server(config: ConsoleConfig, no_open: bool) -> None: # pragma: no cover
47
+ """Start uvicorn (real entry; monkeypatched in tests).
48
+
49
+ Host bind is mode-dependent:
50
+ local -> 127.0.0.1 (loopback only; the app's Host guard is a second belt)
51
+ docker -> 0.0.0.0 (the container must be reachable via published ports;
52
+ the local-mode Host guard does NOT run in docker mode,
53
+ so the controls are the bearer gate (LM_API_KEY) plus
54
+ the MCP transport-security Host allowlist).
55
+ Docker mode has no per-launch session token, so there is no tokened URL to
56
+ open and the browser-open path is skipped entirely (there is no browser in
57
+ the container regardless).
58
+ """
59
+ import uvicorn
60
+
61
+ from .app import create_app
62
+ from .engine import EngineGateway
63
+ from .events import EventLog
64
+
65
+ event_log = EventLog(config.data_root)
66
+ gateway = EngineGateway(config, event_log)
67
+ app = create_app(config, gateway, event_log)
68
+ if config.mode == "docker":
69
+ host = "0.0.0.0" # noqa: S104 — intentional; see docstring (bearer + allowlist gate)
70
+ url = f"http://0.0.0.0:{config.port}/"
71
+ else:
72
+ host = "127.0.0.1"
73
+ url = f"http://127.0.0.1:{config.port}/?token={config.session_token}"
74
+ if not no_open:
75
+ import webbrowser
76
+
77
+ webbrowser.open(url)
78
+ sys.stdout.write(f"lean-memory-console serving at {url}\n")
79
+ try:
80
+ uvicorn.run(app, host=host, port=config.port, log_level="info")
81
+ finally:
82
+ gateway.close()
83
+ event_log.close()
84
+
85
+
86
+ def main(argv=None) -> int:
87
+ parser = argparse.ArgumentParser(prog="lean-memory-console")
88
+ parser.add_argument(
89
+ "--print-compose-path", action="store_true",
90
+ help="print the packaged docker-compose.yml path and exit",
91
+ )
92
+ sub = parser.add_subparsers(dest="command")
93
+
94
+ p_serve = sub.add_parser("serve", help="run the read-only console")
95
+ p_serve.add_argument("--root", default=None)
96
+ p_serve.add_argument("--port", type=int, default=None)
97
+ p_serve.add_argument("--no-open", action="store_true")
98
+ p_serve.add_argument(
99
+ "--docker",
100
+ action="store_true",
101
+ help=(
102
+ "run in Docker mode: bearer auth (LM_API_KEY required), bind 0.0.0.0, "
103
+ "register the /mcp mount. Explicit flag — no env-based magic detection."
104
+ ),
105
+ )
106
+
107
+ p_mcp = sub.add_parser("mcp", help="run the observing MCP stdio server")
108
+ p_mcp.add_argument("--root", default=None)
109
+
110
+ args = parser.parse_args(argv)
111
+
112
+ if args.print_compose_path:
113
+ sys.stdout.write(f"{_compose_path()}\n")
114
+ return 0
115
+
116
+ if args.command == "serve":
117
+ root = resolve_data_root(args.root)
118
+ _validate_serve_root(root)
119
+ # Explicit mode selection — no magic env detection. --docker selects the
120
+ # containerized entrypoint: bearer auth, 0.0.0.0 bind, /mcp mount. Boot
121
+ # validation (LM_API_KEY required -> SystemExit(2)) comes free from
122
+ # load_config("docker").
123
+ mode = "docker" if args.docker else "local"
124
+ config = load_config(mode, cli_root=args.root, port=args.port)
125
+ _run_server(config, no_open=args.no_open)
126
+ return 0
127
+
128
+ if args.command == "mcp":
129
+ config = load_config("local", cli_root=args.root)
130
+ run_stdio(config)
131
+ return 0
132
+
133
+ parser.print_help()
134
+ return 1
@@ -0,0 +1,121 @@
1
+ """Console configuration: data-root resolution, namespace sanitizer mirror,
2
+ reserved-namespace guard, and mode-specific config loading (spec §5, §7, §10).
3
+
4
+ SAFE_NS_RE and sanitize_namespace mirror the engine's private sanitizer
5
+ (memory.py:38, 70-71). The mirror is guarded by the fail-loud tripwire in
6
+ inspect_sql.py (spec §13) so engine drift turns the suite red.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import secrets
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ # Mirror of engine memory.py:38 _SAFE_NS = re.compile(r"[^A-Za-z0-9_.-]")
19
+ SAFE_NS_RE: re.Pattern = re.compile(r"[^A-Za-z0-9_.-]")
20
+
21
+ DEFAULT_DATA_ROOT = Path("~/.lean_memory")
22
+ DEFAULT_PORT = 8377
23
+
24
+
25
+ def sanitize_namespace(name: str) -> str:
26
+ """Mirror of memory.py:70-71 safe = _SAFE_NS.sub("_", name) or "default"."""
27
+ return SAFE_NS_RE.sub("_", name) or "default"
28
+
29
+
30
+ def is_reserved_namespace(name: str) -> bool:
31
+ """True when the sanitized namespace begins with '_' (collides with sidecars
32
+ like _events.db); the data plane rejects these (spec §5)."""
33
+ return sanitize_namespace(name).startswith("_")
34
+
35
+
36
+ def ns_db_path(data_root: Path, namespace: str) -> Path:
37
+ """The engine store file for a namespace: root / f'{safe}.db'."""
38
+ return data_root / f"{sanitize_namespace(namespace)}.db"
39
+
40
+
41
+ def resolve_data_root(cli_root: str | None) -> Path:
42
+ """One rule, both commands (spec §10): --root > LM_DATA_ROOT > ~/.lean_memory.
43
+ expanduser is applied; this function never creates the directory."""
44
+ if cli_root:
45
+ return Path(cli_root).expanduser()
46
+ env = os.environ.get("LM_DATA_ROOT")
47
+ if env:
48
+ return Path(env).expanduser()
49
+ return DEFAULT_DATA_ROOT.expanduser()
50
+
51
+
52
+ def parse_allowed_hosts(raw: str | None) -> list[str]:
53
+ """Parse LM_MCP_ALLOWED_HOSTS (comma-separated host patterns) into a list.
54
+
55
+ Whitespace around each entry is stripped and empty entries dropped, so
56
+ "a:*, b:*" and "a:*,,b:*" both yield ["a:*", "b:*"]. None/empty → []. The
57
+ patterns are consumed verbatim by the MCP transport-security allow-list
58
+ (exact match or a trailing ":*" port wildcard — see routes/mcp.py)."""
59
+ if not raw:
60
+ return []
61
+ return [part.strip() for part in raw.split(",") if part.strip()]
62
+
63
+
64
+ @dataclass
65
+ class ConsoleConfig:
66
+ data_root: Path
67
+ mode: str # "local" | "docker"
68
+ api_key: str | None = None
69
+ port: int = DEFAULT_PORT
70
+ models: str = "auto" # "auto" | "stub"
71
+ session_token: str | None = None
72
+ # Docker mode only: extra Host patterns the MCP mount accepts, ADDED to the
73
+ # loopback defaults. Set via LM_MCP_ALLOWED_HOSTS so a container published on
74
+ # a LAN IP/hostname (the shipped compose flow, no reverse proxy) is reachable.
75
+ mcp_allowed_hosts: list[str] = field(default_factory=list)
76
+
77
+
78
+ def load_config(
79
+ mode: str, cli_root: str | None = None, port: int | None = None
80
+ ) -> ConsoleConfig:
81
+ """Build a ConsoleConfig for the given mode.
82
+
83
+ docker: LM_API_KEY is required — a missing key raises SystemExit(2) with a
84
+ clear message. No per-launch session token.
85
+ local: a fresh random session_token is minted (embedded in the tokened URL).
86
+ """
87
+ data_root = resolve_data_root(cli_root)
88
+ models = os.environ.get("LM_CONSOLE_MODELS", "auto")
89
+ if models not in ("auto", "stub"):
90
+ models = "auto"
91
+ resolved_port = port if port is not None else DEFAULT_PORT
92
+
93
+ if mode == "docker":
94
+ api_key = os.environ.get("LM_API_KEY")
95
+ if not api_key:
96
+ print(
97
+ "LM_API_KEY is required in Docker mode; refusing to boot.",
98
+ file=sys.stderr,
99
+ )
100
+ raise SystemExit(2)
101
+ return ConsoleConfig(
102
+ data_root=data_root,
103
+ mode="docker",
104
+ api_key=api_key,
105
+ port=resolved_port,
106
+ models=models,
107
+ session_token=None,
108
+ mcp_allowed_hosts=parse_allowed_hosts(
109
+ os.environ.get("LM_MCP_ALLOWED_HOSTS")
110
+ ),
111
+ )
112
+
113
+ # local mode
114
+ return ConsoleConfig(
115
+ data_root=data_root,
116
+ mode="local",
117
+ api_key=None,
118
+ port=resolved_port,
119
+ models=models,
120
+ session_token=secrets.token_urlsafe(24),
121
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ # deploy/docker-compose.yml — single-tenant lean-memory-console (Docker mode).
2
+ #
3
+ # Usage:
4
+ # LM_API_KEY=$(openssl rand -hex 24) docker compose -f deploy/docker-compose.yml up -d
5
+ #
6
+ # Single source of truth (spec §9): the console CLI packages this exact file
7
+ # into the wheel; the plugin's /memory:server-up|down commands resolve it via
8
+ # docker compose -f "$(lean-memory-console --print-compose-path)" up -d
9
+ services:
10
+ console:
11
+ build:
12
+ context: ..
13
+ dockerfile: deploy/Dockerfile
14
+ target: full # full is the default first-run image (real models)
15
+ ports:
16
+ - "8377:8377"
17
+ environment:
18
+ # LM_API_KEY is required in Docker mode — compose refuses to start
19
+ # without it (the console also boot-checks it).
20
+ - LM_API_KEY=${LM_API_KEY:?set LM_API_KEY - e.g. openssl rand -hex 24}
21
+ - LM_DATA_ROOT=/data
22
+ - PORT=8377
23
+ - LM_CONSOLE_MODELS=auto
24
+ # Remote-host deployments (reached over the LAN, no reverse proxy) must
25
+ # list the hostname(s)/IP(s) the container is reached by, or the MCP mount
26
+ # 421s on the Host check. Comma-separated; ":*" matches any port. The
27
+ # bearer gate (LM_API_KEY) remains the primary access control.
28
+ # - LM_MCP_ALLOWED_HOSTS=myhost:*
29
+ volumes:
30
+ - lm_data:/data
31
+ - hf_cache:/root/.cache/huggingface
32
+ restart: unless-stopped
33
+
34
+ volumes:
35
+ lm_data:
36
+ hf_cache: