From dde62f918fb745561db3cb045a3721bbb0f23238 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 31 Jul 2026 18:35:00 +0800 Subject: [PATCH 1/2] feat: add persistent admin workspace gateway and chat stack --- .github/workflows/compliance.yml | 7 + coding_tools_mcp/admin.py | 891 +++++++++++++ coding_tools_mcp/chat_cli.py | 148 +++ coding_tools_mcp/codex_sessions.py | 610 +++++++++ coding_tools_mcp/oauth.py | 617 ++++++++- coding_tools_mcp/oauth_store.py | 1373 ++++++++++++++++++++ coding_tools_mcp/processes.py | 50 +- coding_tools_mcp/secret_vault.py | 237 ++++ coding_tools_mcp/server.py | 1217 +++++++++++++++-- coding_tools_mcp/settings_definition.py | 331 +++++ coding_tools_mcp/settings_store.py | 144 ++ coding_tools_mcp/transcript.py | 673 ++++++++++ coding_tools_mcp/transport_http.py | 6 +- coding_tools_mcp/upstream.py | 1181 +++++++++++++++++ coding_tools_mcp/webui.py | 37 + coding_tools_mcp/webui_dist/admin.html | 1147 ++++++++++++++++ coding_tools_mcp/workspace_binding.py | 85 ++ coding_tools_mcp/workspace_catalog.py | 206 +++ npm/coding-tools-mcp/test/launcher.test.js | 86 +- pyproject.toml | 1 + tests/compliance/test_chat_persistence.py | 407 ++++++ tests/compliance/test_docs_required.py | 69 + tests/compliance/test_mcp_admin.py | 449 +++++++ tests/compliance/test_mcp_contract.py | 25 +- tests/compliance/test_oauth_persistence.py | 139 ++ tests/compliance/test_runtime_helpers.py | 450 +++++-- tests/compliance/test_upstream_gateway.py | 557 ++++++++ tests/test_integration_contract_v022.py | 352 +++++ tests/test_oauth_fail_closed.py | 154 +++ tests/test_oauth_integration.py | 428 ++++++ tests/test_oauth_refresh.py | 267 ++++ tests/test_oauth_signing.py | 134 ++ tests/test_oauth_store.py | 639 +++++++++ tests/test_phase11_packaging.py | 56 + tests/test_release_checks.py | 7 + tests/test_settings_foundation.py | 322 +++++ tests/test_telemetry.py | 7 +- tests/test_webui.py | 48 + tests/test_workspace_session_binding.py | 523 ++++++++ uv.lock | 59 +- webui/package.json | 11 + webui/scripts/build.mjs | 38 + webui/src/admin.css | 103 ++ webui/src/admin.html | 146 +++ webui/src/admin.js | 596 +++++++++ webui/src/settings-copy.js | 50 + webui/src/settings-model.js | 93 ++ webui/src/settings-page.js | 82 ++ webui/src/workspace-editor.js | 71 + webui/tests/dom-interactions.test.mjs | 190 +++ webui/tests/security-model.test.mjs | 69 + webui/tests/settings-model.test.mjs | 72 + 52 files changed, 15298 insertions(+), 362 deletions(-) create mode 100644 coding_tools_mcp/admin.py create mode 100644 coding_tools_mcp/chat_cli.py create mode 100644 coding_tools_mcp/codex_sessions.py create mode 100644 coding_tools_mcp/oauth_store.py create mode 100644 coding_tools_mcp/secret_vault.py create mode 100644 coding_tools_mcp/settings_definition.py create mode 100644 coding_tools_mcp/settings_store.py create mode 100644 coding_tools_mcp/transcript.py create mode 100644 coding_tools_mcp/upstream.py create mode 100644 coding_tools_mcp/webui.py create mode 100644 coding_tools_mcp/webui_dist/admin.html create mode 100644 coding_tools_mcp/workspace_binding.py create mode 100644 coding_tools_mcp/workspace_catalog.py create mode 100644 tests/compliance/test_chat_persistence.py create mode 100644 tests/compliance/test_mcp_admin.py create mode 100644 tests/compliance/test_oauth_persistence.py create mode 100644 tests/compliance/test_upstream_gateway.py create mode 100644 tests/test_integration_contract_v022.py create mode 100644 tests/test_oauth_fail_closed.py create mode 100644 tests/test_oauth_integration.py create mode 100644 tests/test_oauth_refresh.py create mode 100644 tests/test_oauth_signing.py create mode 100644 tests/test_oauth_store.py create mode 100644 tests/test_phase11_packaging.py create mode 100644 tests/test_settings_foundation.py create mode 100644 tests/test_webui.py create mode 100644 tests/test_workspace_session_binding.py create mode 100644 webui/package.json create mode 100644 webui/scripts/build.mjs create mode 100644 webui/src/admin.css create mode 100644 webui/src/admin.html create mode 100644 webui/src/admin.js create mode 100644 webui/src/settings-copy.js create mode 100644 webui/src/settings-model.js create mode 100644 webui/src/settings-page.js create mode 100644 webui/src/workspace-editor.js create mode 100644 webui/tests/dom-interactions.test.mjs create mode 100644 webui/tests/security-model.test.mjs create mode 100644 webui/tests/settings-model.test.mjs diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index faeb9a2..6916c22 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -26,6 +26,13 @@ jobs: with: node-version: "22" + - name: Allow setup-node toolchain under Landlock + shell: bash + run: | + node_executable="$(readlink -f "$(command -v node)")" + node_root="$(dirname "$(dirname "$node_executable")")" + echo "CODING_TOOLS_MCP_EXEC_ALLOW_ROOTS=$node_root" >> "$GITHUB_ENV" + - name: Install runtime and CI tools run: | python -m pip install --upgrade pip diff --git a/coding_tools_mcp/admin.py b/coding_tools_mcp/admin.py new file mode 100644 index 0000000..f35564d --- /dev/null +++ b/coding_tools_mcp/admin.py @@ -0,0 +1,891 @@ +"""Authenticated, restart-aware management services for server configuration.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import tempfile +import threading +from pathlib import Path +from typing import Any, Callable + +from .codex_sessions import CodexSessionError, CodexSessionScanner, ScanPolicy +from .oauth_store import OAuthAuthorizationStore +from .secret_vault import SecretVault, SecretVaultError +from .settings_definition import ( + SECRET_REFERENCE_FIELDS, + SettingsValidationError, + normalize_startup_settings_with_warnings, + pending_restart_fields, + schema_payload, +) +from .settings_store import ServerSettingsStore, SettingsStoreError, sanitize_settings +from .telemetry import telemetry_mode +from .transcript import TranscriptStore, TranscriptStoreError, WorkspaceScope +from .upstream import UpstreamConfigError, parse_server_config +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError + +ADMIN_API_PREFIX = "/admin/api" +SERVER_SECRET_VAULT_FILENAME = "server-secrets.json" +SENSITIVE_KEY_RE = re.compile( + r"(?:^|[_-])(token|secret|credential|api[_-]?key|password|passwd|authorization)(?:$|[_-])", + re.I, +) + + +class AdminServiceError(ValueError): + status = 400 + code = "admin_error" + + +class AdminConflictError(AdminServiceError): + status = 409 + code = "stale_revision" + + +class AdminUnavailableError(AdminServiceError): + status = 503 + code = "admin_unavailable" + + +class AdminNotFoundError(AdminServiceError): + status = 404 + code = "not_found" + + +def document_revision(value: Any) -> str: + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _json_copy(value: Any) -> Any: + try: + return json.loads(json.dumps(value, ensure_ascii=False)) + except TypeError as exc: + raise AdminServiceError(f"Value must be JSON serializable: {exc}") from exc + + +def _redact(value: Any, *, key: str = "") -> Any: + if key.endswith("_secret_ref"): + return {"configured": bool(value)} + if isinstance(value, dict): + if "secret_ref" in value: + return {"source": "secret_ref", "configured": bool(value.get("secret_ref"))} + if "env_ref" in value: + return {"source": "env_ref", "configured": bool(value.get("env_ref"))} + return {str(child): _redact(item, key=str(child)) for child, item in value.items()} + if isinstance(value, list): + return [_redact(item, key=key) for item in value] + if SENSITIVE_KEY_RE.search(key): + return "" if value not in (None, "") else value + return value + + +def _redact_oauth_item(item: dict[str, Any]) -> dict[str, Any]: + result = _json_copy(item) + for key in ( + "client_secret_digest", + "secret_ref", + "token_hash", + "refresh_token", + "access_token", + "signing_secret", + ): + result.pop(key, None) + return _redact(result) + + +def _atomic_write_json(path: Path, document: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(document, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + tmp_path.chmod(0o600) + os.replace(tmp_path, path) + except OSError as exc: + raise AdminUnavailableError(f"Could not atomically save configuration: {exc}") from exc + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + + +def _read_gateway_document(path: Path) -> dict[str, Any]: + if not path.exists(): + return {"servers": {}} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + raise AdminUnavailableError(f"Could not read Gateway configuration: {exc}") from exc + except json.JSONDecodeError as exc: + raise AdminServiceError(f"Gateway configuration is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise AdminServiceError("Gateway configuration must be a JSON object.") + servers = raw.get("servers", raw) + if not isinstance(servers, dict): + raise AdminServiceError("Gateway configuration must contain a servers object.") + return {"servers": _json_copy(servers)} + + + + +def gateway_file_revision(path: str | Path) -> str: + return document_revision(_read_gateway_document(Path(path).expanduser())) + +def _secret_refs(value: Any) -> list[str]: + refs: list[str] = [] + if isinstance(value, dict): + ref = value.get("secret_ref") + if isinstance(ref, str) and ref: + refs.append(ref) + for child in value.values(): + refs.extend(_secret_refs(child)) + elif isinstance(value, list): + for child in value: + refs.extend(_secret_refs(child)) + return refs + + +def _validate_gateway_document(document: dict[str, Any], vault: SecretVault) -> dict[str, Any]: + servers = document.get("servers") + if not isinstance(servers, dict): + raise AdminServiceError("Gateway configuration must contain a servers object.") + normalized: dict[str, Any] = {} + for alias, value in servers.items(): + if not isinstance(alias, str) or not isinstance(value, dict): + raise AdminServiceError("Gateway server entries must use string aliases and object values.") + try: + parse_server_config(alias, value) + except UpstreamConfigError as exc: + raise AdminServiceError(str(exc)) from exc + refs = _secret_refs(value) + if refs and not vault.enabled(): + raise AdminUnavailableError( + "Gateway secret_ref requires an enabled server Secret Vault." + ) + for ref in refs: + try: + vault.get_secret(ref) + except SecretVaultError as exc: + raise AdminUnavailableError( + f"Gateway secret_ref {ref!r} cannot be resolved." + ) from exc + env = value.get("env") + if isinstance(env, dict): + for env_name, env_value in env.items(): + if ( + isinstance(env_name, str) + and SENSITIVE_KEY_RE.search(env_name) + and isinstance(env_value, str) + ): + raise AdminServiceError( + f"Sensitive Gateway environment value {env_name!r} must use env_ref or secret_ref." + ) + headers = value.get("headers") + if isinstance(headers, dict): + for header_name, header_value in headers.items(): + if ( + isinstance(header_name, str) + and ( + header_name.lower() in {"authorization", "proxy-authorization"} + or SENSITIVE_KEY_RE.search(header_name) + ) + and isinstance(header_value, str) + and header_value + ): + raise AdminServiceError( + "Sensitive Gateway headers cannot be persisted as plaintext." + ) + normalized[alias] = _json_copy(value) + return {"servers": normalized} + + +class AdminService: + """Pure service layer used by the HTTP handler; it contains no handler state.""" + + def __init__( + self, + *, + settings_store: ServerSettingsStore, + active_settings: dict[str, Any], + fallback_workspace: str | Path, + gateway_path: str | Path, + active_gateway_revision: str, + secret_vault: SecretVault, + oauth_store: OAuthAuthorizationStore | None = None, + active_gateway_status: Callable[[], dict[str, Any]] | None = None, + transcript_store: TranscriptStore | None = None, + session_scanner: CodexSessionScanner | None = None, + ) -> None: + self.settings_store = settings_store + self.active_settings = _json_copy(active_settings) + self.fallback_workspace = Path(fallback_workspace).expanduser().resolve(strict=True) + self.gateway_path = Path(gateway_path).expanduser() + self.active_gateway_revision = active_gateway_revision + self.secret_vault = secret_vault + self.oauth_store = oauth_store + self.active_gateway_status = active_gateway_status + self.transcript_store = transcript_store + self.session_scanner = session_scanner or CodexSessionScanner() + self._settings_lock = threading.Lock() + self._gateway_lock = threading.Lock() + + def status_payload(self) -> dict[str, Any]: + mode = telemetry_mode() + return { + "ok": True, + "admin_api": 1, + "settings": {"available": True}, + "oauth": {"available": self.oauth_store is not None}, + "gateway": {"available": True, "dynamic_reload": False}, + "chat": {"available": self.transcript_store is not None}, + "vault": {"enabled": self.secret_vault.enabled()}, + "telemetry": { + "mode": mode, + "docs": "docs/telemetry.md", + }, + } + + def settings_payload(self) -> dict[str, Any]: + result = self.settings_store.read_result() + persisted = result.settings + pending = set(pending_restart_fields(self.active_settings, persisted)) + pending.update( + field + for field in SECRET_REFERENCE_FIELDS + if self.active_settings.get(field) != persisted.get(field) + ) + schema = schema_payload() + schema["restart_fields"] = sorted( + set(schema.get("restart_fields", ())) | set(SECRET_REFERENCE_FIELDS) + ) + return { + "ok": True, + "active": sanitize_settings(self.active_settings), + "persisted": sanitize_settings(persisted), + "persisted_revision": document_revision(persisted), + "pending_restart": sorted(pending), + "restart_required": bool(pending), + "migration_warnings": list(result.warnings), + "schema": schema, + } + + def validate_settings(self, body: dict[str, Any]) -> dict[str, Any]: + current = self.settings_store.read() + updates = body.get("updates", body) + if not isinstance(updates, dict): + raise AdminServiceError("settings updates must be an object.") + try: + normalized, warnings = normalize_startup_settings_with_warnings( + current, updates, self.fallback_workspace + ) + except SettingsValidationError as exc: + raise AdminServiceError(str(exc)) from exc + pending = set(pending_restart_fields(self.active_settings, normalized)) + pending.update( + field + for field in SECRET_REFERENCE_FIELDS + if self.active_settings.get(field) != normalized.get(field) + ) + return { + "ok": True, + "valid": True, + "normalized": sanitize_settings(normalized), + "pending_restart": sorted(pending), + "restart_required": bool(pending), + "warnings": list(warnings), + } + + def save_settings(self, body: dict[str, Any]) -> dict[str, Any]: + expected = body.get("expected_revision") + updates = body.get("updates") + if not isinstance(expected, str) or not expected: + raise AdminServiceError("expected_revision is required.") + if not isinstance(updates, dict): + raise AdminServiceError("updates must be an object.") + with self._settings_lock: + current = self.settings_store.read() + current_revision = document_revision(current) + if not _constant_equal(expected, current_revision): + raise AdminConflictError( + "Settings changed after this page was loaded; reload before saving." + ) + try: + normalized, warnings = normalize_startup_settings_with_warnings( + current, updates, self.fallback_workspace + ) + write_warnings = self.settings_store.write(normalized) + except (SettingsStoreError, SettingsValidationError) as exc: + raise AdminServiceError(str(exc)) from exc + payload = self.settings_payload() + payload["warnings"] = list(dict.fromkeys((*warnings, *write_warnings))) + return payload + + def gateway_payload(self) -> dict[str, Any]: + document = _read_gateway_document(self.gateway_path) + revision = document_revision(document) + status = self.active_gateway_status() if self.active_gateway_status else None + return { + "ok": True, + "persisted": _redact(document), + "persisted_revision": revision, + "active_revision": self.active_gateway_revision, + "pending_restart": revision != self.active_gateway_revision, + "restart_required": revision != self.active_gateway_revision, + "active_status": _redact(status) if isinstance(status, dict) else None, + "dynamic_reload": False, + } + + def save_gateway(self, body: dict[str, Any]) -> dict[str, Any]: + expected = body.get("expected_revision") + document = body.get("document") + if not isinstance(expected, str) or not expected: + raise AdminServiceError("expected_revision is required.") + if not isinstance(document, dict): + raise AdminServiceError("document must be an object.") + with self._gateway_lock: + current = _read_gateway_document(self.gateway_path) + if not _constant_equal(expected, document_revision(current)): + raise AdminConflictError( + "Gateway configuration changed after this page was loaded; reload before saving." + ) + normalized = _validate_gateway_document(document, self.secret_vault) + _atomic_write_json(self.gateway_path, normalized) + return self.gateway_payload() + + def secrets_payload(self) -> dict[str, Any]: + if not self.secret_vault.enabled(): + raise AdminUnavailableError("Server Secret Vault is not enabled.") + try: + names = self.secret_vault.list_names() + except SecretVaultError as exc: + raise AdminUnavailableError(str(exc)) from exc + return { + "ok": True, + "vault_enabled": self.secret_vault.enabled(), + "secrets": [{"name": name, "configured": True} for name in names], + } + + def set_secret(self, name: str, body: dict[str, Any]) -> dict[str, Any]: + value = body.get("value") + if not isinstance(value, str) or not value: + raise AdminServiceError("Secret value must be a non-empty string.") + try: + existed = name in self.secret_vault.list_names() + self.secret_vault.set_secret(name, value) + except SecretVaultError as exc: + raise AdminUnavailableError(str(exc)) from exc + return { + "ok": True, + "name": name, + "configured": True, + "created": not existed, + "affected_count": 1, + } + + def delete_secret(self, name: str) -> dict[str, Any]: + try: + deleted = self.secret_vault.delete_secret(name) + except SecretVaultError as exc: + raise AdminUnavailableError(str(exc)) from exc + return {"ok": True, "name": name, "affected_count": 1 if deleted else 0} + + def oauth_payload(self, collection: str, query: dict[str, str]) -> dict[str, Any]: + store = self._require_oauth_store() + client_id = query.get("client_id") or None + if collection == "clients": + items = store.list_clients() + elif collection == "grants": + items = store.list_grants(client_id) + elif collection == "tokens": + items = store.list_access_tokens(client_id) + elif collection == "refresh-families": + items = store.list_refresh_token_families(client_id) + elif collection == "signing-keys": + items = store.list_signing_keys() + elif collection == "audit": + try: + limit = int(query.get("limit", "100")) + except ValueError as exc: + raise AdminServiceError("audit limit must be an integer.") from exc + items = store.list_audit_events(limit=limit) + else: + raise AdminNotFoundError("Unknown OAuth collection.") + redacted = [_redact_oauth_item(item) for item in items] + return {"ok": True, "items": redacted, "count": len(redacted)} + + def oauth_action(self, resource: str, identifier: str, action: str) -> dict[str, Any]: + store = self._require_oauth_store() + before_events = { + str(item.get("event_id")) + for item in store.list_audit_events(limit=500) + if item.get("event_id") is not None + } + changed = False + exists = False + if resource == "clients" and action in {"enable", "disable"}: + item = store.get_client(identifier) + exists = item is not None + if item is not None: + desired = action == "enable" + changed = ( + bool(item.get("enabled")) is not desired + and store.set_client_enabled(identifier, desired) + ) + elif resource == "grants" and action == "revoke": + item = store.get_grant(identifier) + exists = item is not None + if item is not None: + changed = ( + item.get("revoked_at") is None + and store.revoke_grant(identifier) + ) + elif resource == "tokens" and action == "revoke": + item = next((row for row in store.list_access_tokens() if row.get("jti") == identifier), None) + exists = item is not None + if item is not None: + changed = ( + item.get("revoked_at") is None + and store.revoke_access_token(identifier) + ) + elif resource == "refresh-families" and action == "revoke": + item = next( + (row for row in store.list_refresh_token_families() if row.get("family_id") == identifier), + None, + ) + exists = item is not None + if item is not None: + changed = ( + item.get("revoked_at") is None + and store.revoke_refresh_family(identifier) + ) + elif resource == "signing-keys" and action in {"activate", "retire", "revoke"}: + item = next((row for row in store.list_signing_keys() if row.get("kid") == identifier), None) + exists = item is not None + if item is not None: + desired_status = {"activate": "active", "retire": "retired", "revoke": "revoked"}[action] + if action == "activate": + applied = store.activate_signing_key(identifier) + elif action == "retire": + applied = store.retire_signing_key(identifier) + else: + applied = store.revoke_signing_key(identifier) + changed = item.get("status") != desired_status and applied + else: + raise AdminNotFoundError("Unknown OAuth management action.") + audit_event_id = None + if changed: + for event in store.list_audit_events(limit=500): + candidate = event.get("event_id") + if candidate is not None and str(candidate) not in before_events: + audit_event_id = str(candidate) + break + return { + "ok": True, + "resource": resource, + "id": identifier, + "action": action, + "found": exists, + "affected_count": 1 if changed else 0, + "audit_event_id": audit_event_id, + } + + def workspaces_payload(self) -> dict[str, Any]: + current = self.settings_store.read() + try: + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + except WorkspaceCatalogError as exc: + raise AdminServiceError(str(exc)) from exc + return { + "ok": True, + **catalog.settings_payload(), + "persisted_revision": document_revision(current), + } + + def workspace_add(self, body: dict[str, Any]) -> dict[str, Any]: + expected = _required_revision(body) + entry = body.get("workspace") + if not isinstance(entry, dict): + raise AdminServiceError("workspace must be an object.") + with self._settings_lock: + current = self._checked_settings(expected) + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entries = [item.payload() for item in catalog.entries] + new_entry = { + "id": entry.get("id"), + "name": entry.get("name"), + "root": entry.get("root"), + "enabled": entry.get("enabled", True), + "default": entry.get("default", False), + } + entries.append(new_entry) + default_id = str(new_entry["id"]) if new_entry["default"] else catalog.default_id + for item in entries: + item["default"] = item.get("id") == default_id + self._write_workspace_settings(current, entries, default_id) + return self.workspaces_payload() + + def workspace_disable(self, identifier: str, body: dict[str, Any]) -> dict[str, Any]: + expected = _required_revision(body) + with self._settings_lock: + current = self._checked_settings(expected) + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + if identifier == catalog.default_id: + raise AdminServiceError("The default Workspace cannot be disabled.") + entries = [item.payload() for item in catalog.entries] + target = next((item for item in entries if item["id"] == identifier), None) + if target is None: + raise AdminNotFoundError("Workspace is not present in the catalog.") + target["enabled"] = False + self._write_workspace_settings(current, entries, catalog.default_id) + return self.workspaces_payload() + + def workspace_default(self, identifier: str, body: dict[str, Any]) -> dict[str, Any]: + expected = _required_revision(body) + with self._settings_lock: + current = self._checked_settings(expected) + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entries = [item.payload() for item in catalog.entries] + target = next((item for item in entries if item["id"] == identifier), None) + if target is None: + raise AdminNotFoundError("Workspace is not present in the catalog.") + if not target["enabled"]: + raise AdminServiceError("A disabled Workspace cannot become the default.") + for item in entries: + item["default"] = item["id"] == identifier + self._write_workspace_settings(current, entries, identifier) + return self.workspaces_payload() + + def workspace_check(self, identifier: str) -> dict[str, Any]: + current = self.settings_store.read() + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entry = next((item for item in catalog.entries if item.id == identifier), None) + if entry is None: + raise AdminNotFoundError("Workspace is not present in the catalog.") + return { + "ok": True, + "workspace": entry.payload(), + "check": { + "exists": entry.root.exists(), + "is_directory": entry.root.is_dir(), + "enabled": entry.enabled, + "is_default": entry.default, + }, + } + + def chat_conversations(self, query: dict[str, str]) -> dict[str, Any]: + store = self._require_transcript_store() + workspace_id = query.get("workspace_id") or None + if workspace_id is not None: + self._workspace_scope(workspace_id) + page = _query_int(query, "page", 1) + page_size = _query_int(query, "page_size", 50) + payload = store.list_conversations( + workspace_id, + page=page, + page_size=page_size, + query=query.get("query") or None, + ) + return {"ok": True, **payload} + + def chat_conversation_detail(self, workspace_id: str, conversation_id: str, query: dict[str, str]) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + payload = store.conversation_detail( + workspace_id, + conversation_id, + message_page=_query_int(query, "message_page", 1), + message_page_size=_query_int(query, "message_page_size", 100), + context_page=_query_int(query, "context_page", 1), + context_page_size=_query_int(query, "context_page_size", 100), + ) + if payload is None: + raise AdminNotFoundError("Conversation is not present in the selected Workspace.") + return {"ok": True, **payload} + + def chat_record_messages(self, workspace_id: str, conversation_id: str, body: dict[str, Any]) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + messages = body.get("messages") + if not isinstance(messages, list): + raise AdminServiceError("messages must be a list.") + return { + "ok": True, + **store.record_messages( + workspace_id, + conversation_id, + messages, + title=body.get("title"), + source=body.get("source") or "admin-api", + ), + } + + def chat_record_context(self, workspace_id: str, conversation_id: str, body: dict[str, Any]) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + entries = body.get("entries") + if not isinstance(entries, list): + raise AdminServiceError("entries must be a list.") + return { + "ok": True, + **store.record_context( + workspace_id, + conversation_id, + entries, + title=body.get("title"), + source=body.get("source") or "admin-api", + ), + } + + def chat_delete(self, resource: str, workspace_id: str, identifier: str) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + if resource == "messages": + result = store.delete_message(workspace_id, identifier) + elif resource == "context": + result = store.delete_context(workspace_id, identifier) + elif resource == "conversations": + result = store.delete_conversation(workspace_id, identifier) + elif resource == "sessions": + result = store.delete_imported_session(workspace_id, identifier) + else: + raise AdminNotFoundError("Unknown chat deletion resource.") + return {"ok": True, **result} + + def chat_clear_workspace(self, workspace_id: str) -> dict[str, Any]: + store = self._require_transcript_store() + self._workspace_scope(workspace_id) + return {"ok": True, **store.clear_workspace(workspace_id)} + + def codex_scan(self, body: dict[str, Any]) -> dict[str, Any]: + workspace_id = body.get("workspace_id") + if not isinstance(workspace_id, str): + raise AdminServiceError("workspace_id is required.") + scope = self._workspace_scope(workspace_id) + roots = body.get("roots") + if roots is not None and (not isinstance(roots, list) or not all(isinstance(item, str) for item in roots)): + raise AdminServiceError("roots must be a list of relative paths.") + try: + policy = _scan_policy(body) + return {"ok": True, **self.session_scanner.scan(scope, roots=roots, policy=policy)} + except CodexSessionError as exc: + raise AdminServiceError(str(exc)) from exc + + def codex_import(self, body: dict[str, Any]) -> dict[str, Any]: + store = self._require_transcript_store() + workspace_id = body.get("workspace_id") + candidate_ids = body.get("candidate_ids") + if not isinstance(workspace_id, str): + raise AdminServiceError("workspace_id is required.") + if not isinstance(candidate_ids, list) or not all(isinstance(item, str) for item in candidate_ids): + raise AdminServiceError("candidate_ids must be a list of strings.") + roots = body.get("roots") + if roots is not None and (not isinstance(roots, list) or not all(isinstance(item, str) for item in roots)): + raise AdminServiceError("roots must be a list of relative paths.") + scope = self._workspace_scope(workspace_id) + try: + return { + "ok": True, + **self.session_scanner.import_candidates( + store, + scope, + candidate_ids=candidate_ids, + roots=roots, + policy=_scan_policy(body), + ), + } + except (CodexSessionError, TranscriptStoreError) as exc: + raise AdminServiceError(str(exc)) from exc + + def codex_sessions(self, query: dict[str, str]) -> dict[str, Any]: + store = self._require_transcript_store() + workspace_id = query.get("workspace_id") or None + if workspace_id is not None: + self._workspace_scope(workspace_id) + return { + "ok": True, + **store.list_imported_sessions( + workspace_id, + page=_query_int(query, "page", 1), + page_size=_query_int(query, "page_size", 50), + ), + } + + def _workspace_scope(self, workspace_id: str) -> WorkspaceScope: + current = self.settings_store.read() + try: + catalog = WorkspaceCatalog.from_settings(current, self.fallback_workspace) + entry = catalog.get(workspace_id) + except WorkspaceCatalogError as exc: + raise AdminNotFoundError("Workspace is unknown or disabled.") from exc + return WorkspaceScope.create(entry.id, entry.root) + + def _require_transcript_store(self) -> TranscriptStore: + if self.transcript_store is None: + raise AdminUnavailableError("Chat persistence is not configured.") + return self.transcript_store + + def dispatch( + self, + method: str, + path: str, + body: dict[str, Any], + query: dict[str, str], + ) -> dict[str, Any]: + relative = path.removeprefix(ADMIN_API_PREFIX).strip("/") + parts = [part for part in relative.split("/") if part] + if method == "GET" and parts == ["status"]: + return self.status_payload() + if method == "GET" and parts == ["settings"]: + return self.settings_payload() + if method == "POST" and parts == ["settings", "validate"]: + return self.validate_settings(body) + if method == "PUT" and parts == ["settings"]: + return self.save_settings(body) + if method == "GET" and parts == ["gateway"]: + return self.gateway_payload() + if method == "PUT" and parts == ["gateway"]: + return self.save_gateway(body) + if method == "GET" and parts == ["secrets"]: + return self.secrets_payload() + if len(parts) == 2 and parts[0] == "secrets" and method == "PUT": + return self.set_secret(parts[1], body) + if len(parts) == 2 and parts[0] == "secrets" and method == "DELETE": + return self.delete_secret(parts[1]) + if method == "GET" and parts == ["workspaces"]: + return self.workspaces_payload() + if method == "POST" and parts == ["workspaces"]: + return self.workspace_add(body) + if len(parts) == 3 and parts[0] == "workspaces" and method == "POST": + if parts[2] == "disable": + return self.workspace_disable(parts[1], body) + if parts[2] == "default": + return self.workspace_default(parts[1], body) + if len(parts) == 3 and parts[0] == "workspaces" and parts[2] == "check" and method == "GET": + return self.workspace_check(parts[1]) + if len(parts) == 2 and parts[0] == "oauth" and method == "GET": + return self.oauth_payload(parts[1], query) + if len(parts) == 4 and parts[0] == "oauth" and method == "POST": + return self.oauth_action(parts[1], parts[2], parts[3]) + if method == "GET" and parts == ["chat", "conversations"]: + return self.chat_conversations(query) + if len(parts) == 4 and parts[:2] == ["chat", "conversations"] and method == "GET": + return self.chat_conversation_detail(parts[2], parts[3], query) + if len(parts) == 5 and parts[:2] == ["chat", "conversations"] and method == "POST": + if parts[4] == "messages": + return self.chat_record_messages(parts[2], parts[3], body) + if parts[4] == "context": + return self.chat_record_context(parts[2], parts[3], body) + if len(parts) == 4 and parts[0] == "chat" and parts[1] in {"messages", "context", "sessions"} and method == "DELETE": + return self.chat_delete(parts[1], parts[2], parts[3]) + if len(parts) == 4 and parts[:2] == ["chat", "conversations"] and method == "DELETE": + return self.chat_delete("conversations", parts[2], parts[3]) + if len(parts) == 4 and parts[:2] == ["chat", "workspaces"] and parts[3] == "clear" and method == "POST": + return self.chat_clear_workspace(parts[2]) + if method == "POST" and parts == ["codex", "sessions", "scan"]: + return self.codex_scan(body) + if method == "POST" and parts == ["codex", "sessions", "import"]: + return self.codex_import(body) + if method == "GET" and parts == ["codex", "sessions"]: + return self.codex_sessions(query) + if len(parts) == 4 and parts[:2] == ["codex", "sessions"] and method == "DELETE": + return self.chat_delete("sessions", parts[2], parts[3]) + raise AdminNotFoundError("Unknown Admin API endpoint.") + + def _checked_settings(self, expected_revision: str) -> dict[str, Any]: + current = self.settings_store.read() + if not _constant_equal(expected_revision, document_revision(current)): + raise AdminConflictError( + "Settings changed after this page was loaded; reload before saving." + ) + return current + + def _write_workspace_settings( + self, + current: dict[str, Any], + entries: list[dict[str, Any]], + default_id: str, + ) -> None: + try: + normalized, _warnings = normalize_startup_settings_with_warnings( + current, + { + "workspace_catalog": entries, + "default_workspace_id": default_id, + }, + self.fallback_workspace, + ) + self.settings_store.write(normalized) + except (SettingsStoreError, SettingsValidationError, WorkspaceCatalogError) as exc: + raise AdminServiceError(str(exc)) from exc + + def _require_oauth_store(self) -> OAuthAuthorizationStore: + if self.oauth_store is None: + raise AdminUnavailableError("OAuth persistence is not configured.") + return self.oauth_store + +def _query_int(query: dict[str, str], key: str, default: int) -> int: + raw = query.get(key) + if raw in (None, ""): + return default + assert raw is not None + try: + return int(raw) + except ValueError as exc: + raise AdminServiceError(f"{key} must be an integer.") from exc + + +def _scan_policy(body: dict[str, Any]) -> ScanPolicy: + values: dict[str, int] = {} + for field in ("max_depth", "max_files", "max_file_bytes", "max_total_bytes", "max_messages"): + if field in body: + raw = body[field] + if isinstance(raw, bool): + raise AdminServiceError(f"{field} must be an integer.") + try: + values[field] = int(raw) + except (TypeError, ValueError) as exc: + raise AdminServiceError(f"{field} must be an integer.") from exc + return ScanPolicy(**values).validated() + + +def _required_revision(body: dict[str, Any]) -> str: + value = body.get("expected_revision") + if not isinstance(value, str) or not value: + raise AdminServiceError("expected_revision is required.") + return value + + +def _constant_equal(left: str, right: str) -> bool: + return hashlib.sha256(left.encode("utf-8")).digest() == hashlib.sha256( + right.encode("utf-8") + ).digest() + + +__all__ = [ + "ADMIN_API_PREFIX", + "SERVER_SECRET_VAULT_FILENAME", + "AdminConflictError", + "AdminNotFoundError", + "AdminService", + "AdminServiceError", + "AdminUnavailableError", + "document_revision", + "gateway_file_revision", +] diff --git a/coding_tools_mcp/chat_cli.py b/coding_tools_mcp/chat_cli.py new file mode 100644 index 0000000..000f3d9 --- /dev/null +++ b/coding_tools_mcp/chat_cli.py @@ -0,0 +1,148 @@ +"""Small Workspace-scoped CLI for recording and reading chat persistence.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from .transcript import TranscriptStore, TranscriptStoreError, WorkspaceScope + + +class CliError(ValueError): + pass + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Record Workspace-scoped chat data.") + parser.add_argument("--db-path", required=True) + parser.add_argument("--workspace-id", required=True) + parser.add_argument("--workspace-root", required=True) + sub = parser.add_subparsers(dest="command", required=True) + + record_message = sub.add_parser("record-message") + record_message.add_argument("--conversation-id") + record_message.add_argument("--title") + record_message.add_argument("--message-id") + record_message.add_argument("--role") + record_message.add_argument("--content") + record_message.add_argument("--timestamp") + record_message.add_argument("--source") + record_message.add_argument("--stdin-json", action="store_true") + + record_context = sub.add_parser("record-context") + record_context.add_argument("--conversation-id") + record_context.add_argument("--title") + record_context.add_argument("--context-id") + record_context.add_argument("--kind") + record_context.add_argument("--content") + record_context.add_argument("--timestamp") + record_context.add_argument("--source") + record_context.add_argument("--stdin-json", action="store_true") + + listing = sub.add_parser("list") + listing.add_argument("--page", type=int, default=1) + listing.add_argument("--page-size", type=int, default=50) + listing.add_argument("--query") + + detail = sub.add_parser("detail") + detail.add_argument("--conversation-id", required=True) + detail.add_argument("--page", type=int, default=1) + detail.add_argument("--page-size", type=int, default=100) + return parser + + +def _read_stdin_json() -> dict[str, Any]: + stream = getattr(sys.stdin, "buffer", None) + raw = stream.read() if stream is not None else sys.stdin.read().encode("utf-8", errors="replace") + for encoding in ("utf-8-sig", "gb18030", "utf-16"): + try: + text = raw.decode(encoding) + value = json.loads(text) + if not isinstance(value, dict): + raise CliError("stdin JSON must be an object.") + return value + except UnicodeDecodeError: + continue + except json.JSONDecodeError as exc: + raise CliError(f"stdin is not valid JSON: {exc}") from exc + raise CliError("stdin is not valid UTF-8, UTF-16, or GB18030 text.") + + +def _value(args: argparse.Namespace, payload: dict[str, Any], name: str, default: Any = None) -> Any: + value = getattr(args, name, None) + return value if value not in (None, "") else payload.get(name, default) + + +def run(args: argparse.Namespace) -> dict[str, Any]: + scope = WorkspaceScope.create(args.workspace_id, args.workspace_root) + store = TranscriptStore(Path(args.db_path)) + service = store.scoped(scope.workspace_id, scope.workspace_root) + payload = _read_stdin_json() if getattr(args, "stdin_json", False) else {} + if args.command == "record-message": + conversation_id = _value(args, payload, "conversation_id") + if not conversation_id: + raise CliError("conversation_id is required.") + message = { + "message_id": _value(args, payload, "message_id"), + "role": _value(args, payload, "role", "unknown"), + "content": _value(args, payload, "content", ""), + "timestamp": _value(args, payload, "timestamp"), + "source": _value(args, payload, "source", "chat-cli"), + "metadata": payload.get("metadata", payload.get("metadata_json", {})), + } + return service.record_messages( + str(conversation_id), + [message], + title=_value(args, payload, "title"), + source=_value(args, payload, "source", "chat-cli"), + ) + if args.command == "record-context": + conversation_id = _value(args, payload, "conversation_id") + if not conversation_id: + raise CliError("conversation_id is required.") + entry = { + "context_id": _value(args, payload, "context_id", payload.get("entry_id")), + "kind": _value(args, payload, "kind", "note"), + "content": _value(args, payload, "content", ""), + "timestamp": _value(args, payload, "timestamp"), + "source": _value(args, payload, "source", "chat-cli"), + "metadata": payload.get("metadata", payload.get("metadata_json", {})), + } + return service.record_context( + str(conversation_id), + [entry], + title=_value(args, payload, "title"), + source=_value(args, payload, "source", "chat-cli"), + ) + if args.command == "list": + return service.list_conversations(page=args.page, page_size=args.page_size, query=args.query) + if args.command == "detail": + result = service.conversation_detail( + args.conversation_id, + message_page=args.page, + message_page_size=args.page_size, + context_page=args.page, + context_page_size=args.page_size, + ) + if result is None: + raise CliError("Conversation was not found in this Workspace.") + return result + raise CliError("Unknown command.") + + +def main(argv: list[str] | None = None) -> int: + try: + result = run(build_parser().parse_args(argv)) + text = json.dumps(result, ensure_ascii=False, sort_keys=True) + sys.stdout.write(text.encode("utf-8", errors="replace").decode("utf-8") + "\n") + return 0 + except (CliError, TranscriptStoreError) as exc: + sys.stderr.write(str(exc) + "\n") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/coding_tools_mcp/codex_sessions.py b/coding_tools_mcp/codex_sessions.py new file mode 100644 index 0000000..3c54f0e --- /dev/null +++ b/coding_tools_mcp/codex_sessions.py @@ -0,0 +1,610 @@ +"""Bounded, Workspace-confined discovery and import of Codex session files.""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + +from .transcript import TranscriptStore, WorkspaceScope + + +DEFAULT_MAX_DEPTH = 8 +DEFAULT_MAX_FILES = 500 +DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024 +DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024 +DEFAULT_MAX_MESSAGES = 20_000 +SUPPORTED_SUFFIXES = frozenset({".jsonl", ".json", ".md", ".markdown"}) + + +class CodexSessionError(ValueError): + pass + + +@dataclass(frozen=True) +class ScanPolicy: + max_depth: int = DEFAULT_MAX_DEPTH + max_files: int = DEFAULT_MAX_FILES + max_file_bytes: int = DEFAULT_MAX_FILE_BYTES + max_total_bytes: int = DEFAULT_MAX_TOTAL_BYTES + max_messages: int = DEFAULT_MAX_MESSAGES + + def validated(self) -> "ScanPolicy": + values = { + "max_depth": (self.max_depth, 0, 32), + "max_files": (self.max_files, 1, 10_000), + "max_file_bytes": (self.max_file_bytes, 1024, 100 * 1024 * 1024), + "max_total_bytes": (self.max_total_bytes, 1024, 1024 * 1024 * 1024), + "max_messages": (self.max_messages, 1, 100_000), + } + for name, (value, minimum, maximum) in values.items(): + if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum: + raise CodexSessionError(f"{name} must be between {minimum} and {maximum}.") + return self + + +class CodexSessionScanner: + """Scanner whose cache and candidates are keyed by explicit Workspace identity.""" + + def __init__(self, *, max_cache_entries: int = 1024) -> None: + if not 1 <= max_cache_entries <= 100_000: + raise ValueError("max_cache_entries must be between 1 and 100000.") + self.max_cache_entries = max_cache_entries + self._cache: dict[tuple[str, str, int, int], dict[str, Any]] = {} + + def _cache_put(self, key: tuple[str, str, int, int], parsed: dict[str, Any]) -> None: + workspace_id, relative, _mtime, _size = key + for old_key in list(self._cache): + if old_key[0] == workspace_id and old_key[1] == relative and old_key != key: + self._cache.pop(old_key, None) + while len(self._cache) >= self.max_cache_entries: + self._cache.pop(next(iter(self._cache))) + self._cache[key] = parsed + + def scan( + self, + scope: WorkspaceScope, + *, + roots: list[str] | None = None, + policy: ScanPolicy | None = None, + ) -> dict[str, Any]: + policy = (policy or ScanPolicy()).validated() + resolved_roots = _resolve_roots(scope, roots or ["."]) + candidates: list[dict[str, Any]] = [] + scan_errors: list[dict[str, Any]] = [] + seen_files = 0 + read_bytes = 0 + stop_scan = False + for root in resolved_roots: + if stop_scan: + break + try: + iterator = _iter_candidate_files(scope, root, max_depth=policy.max_depth) + for path in iterator: + if seen_files >= policy.max_files: + scan_errors.append({"code": "file_limit", "message": "Candidate file limit reached."}) + stop_scan = True + break + seen_files += 1 + try: + stat = path.stat() + except OSError: + candidates.append(_file_error_candidate(scope, path, "stat_failed")) + continue + relative = path.relative_to(scope.workspace_root).as_posix() + if stat.st_size > policy.max_file_bytes: + candidates.append( + _file_error_candidate( + scope, + path, + "file_too_large", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + ) + ) + continue + if read_bytes + stat.st_size > policy.max_total_bytes: + scan_errors.append({"code": "total_byte_limit", "message": "Total transcript read limit reached."}) + stop_scan = True + break + cache_key = (scope.workspace_id, relative, stat.st_mtime_ns, stat.st_size) + parsed = self._cache.get(cache_key) + if parsed is None: + parsed = parse_codex_session_file( + scope, + path, + max_file_bytes=policy.max_file_bytes, + max_messages=policy.max_messages, + ) + self._cache_put(cache_key, parsed) + read_bytes += stat.st_size + candidates.append(_preview(parsed)) + except OSError as exc: + scan_errors.append({"code": "root_unreadable", "message": type(exc).__name__}) + candidates.sort(key=lambda item: (item.get("relative_path", ""), item.get("session_id", ""))) + return { + "workspace_id": scope.workspace_id, + "candidates": candidates, + "candidate_count": len(candidates), + "scan_errors": scan_errors, + "scan_error_count": len(scan_errors), + "files_considered": seen_files, + "bytes_read": read_bytes, + "limits": { + "max_depth": policy.max_depth, + "max_files": policy.max_files, + "max_file_bytes": policy.max_file_bytes, + "max_total_bytes": policy.max_total_bytes, + "max_messages": policy.max_messages, + }, + } + + def import_candidates( + self, + store: TranscriptStore, + scope: WorkspaceScope, + *, + candidate_ids: list[str], + roots: list[str] | None = None, + policy: ScanPolicy | None = None, + ) -> dict[str, Any]: + if not isinstance(candidate_ids, list) or not candidate_ids: + raise CodexSessionError("candidate_ids must be a non-empty list.") + wanted = {str(item) for item in candidate_ids} + policy = (policy or ScanPolicy()).validated() + resolved_roots = _resolve_roots(scope, roots or ["."]) + found: dict[str, dict[str, Any]] = {} + files_seen = 0 + total_bytes = 0 + for root in resolved_roots: + for path in _iter_candidate_files(scope, root, max_depth=policy.max_depth): + if files_seen >= policy.max_files or len(found) == len(wanted): + break + files_seen += 1 + try: + stat = path.stat() + except OSError: + continue + if stat.st_size > policy.max_file_bytes or total_bytes + stat.st_size > policy.max_total_bytes: + continue + relative = path.relative_to(scope.workspace_root).as_posix() + candidate_id = _candidate_id(scope.workspace_id, relative) + if candidate_id not in wanted: + continue + cache_key = (scope.workspace_id, relative, stat.st_mtime_ns, stat.st_size) + parsed = self._cache.get(cache_key) + if parsed is None: + parsed = parse_codex_session_file( + scope, + path, + max_file_bytes=policy.max_file_bytes, + max_messages=policy.max_messages, + ) + self._cache_put(cache_key, parsed) + total_bytes += stat.st_size + found[candidate_id] = parsed + inserted = 0 + duplicates = 0 + imported_sessions = 0 + errors: list[dict[str, Any]] = [] + for candidate_id in candidate_ids: + parsed = found.get(candidate_id) + if parsed is None: + errors.append({"candidate_id": candidate_id, "code": "not_found"}) + continue + if parsed.get("fatal_error"): + store.upsert_imported_session(scope.workspace_id, parsed) + errors.append({"candidate_id": candidate_id, "code": str(parsed["fatal_error"])}) + continue + result = store.record_messages( + scope.workspace_id, + str(parsed["conversation_id"]), + list(parsed.get("messages") or []), + title=str(parsed.get("title") or parsed["session_id"]), + source="codex-session-import", + ) + parsed = dict(parsed) + parsed["imported_at"] = __import__("time").time() + store.upsert_imported_session(scope.workspace_id, parsed) + inserted += int(result["inserted_count"]) + duplicates += int(result["duplicate_count"]) + imported_sessions += 1 + return { + "workspace_id": scope.workspace_id, + "requested_count": len(candidate_ids), + "imported_session_count": imported_sessions, + "inserted_count": inserted, + "duplicate_count": duplicates, + "errors": errors, + "error_count": len(errors), + } + + +def parse_codex_session_file( + scope: WorkspaceScope, + path: Path, + *, + max_file_bytes: int = DEFAULT_MAX_FILE_BYTES, + max_messages: int = DEFAULT_MAX_MESSAGES, +) -> dict[str, Any]: + safe_path = _safe_file(scope, path) + relative = safe_path.relative_to(scope.workspace_root).as_posix() + candidate_id = _candidate_id(scope.workspace_id, relative) + try: + stat = safe_path.stat() + except OSError as exc: + return _base_result(scope, relative, candidate_id, fatal_error="stat_failed", parse_errors=[_error("stat_failed", type(exc).__name__)]) + if stat.st_size > max_file_bytes: + return _base_result( + scope, + relative, + candidate_id, + fatal_error="file_too_large", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + parse_errors=[_error("file_too_large", "File exceeds configured size limit.")], + ) + try: + raw = safe_path.read_bytes() + except (OSError, PermissionError) as exc: + return _base_result( + scope, + relative, + candidate_id, + fatal_error="read_failed", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + parse_errors=[_error("read_failed", type(exc).__name__)], + ) + text, encoding, decode_error = _decode_text(raw) + if decode_error: + return _base_result( + scope, + relative, + candidate_id, + fatal_error="invalid_encoding", + file_size=stat.st_size, + file_mtime_ns=stat.st_mtime_ns, + parse_errors=[_error("invalid_encoding", decode_error)], + ) + metadata: dict[str, Any] = {} + messages: list[dict[str, Any]] = [] + parse_errors: list[dict[str, Any]] = [] + suffix = safe_path.suffix.lower() + if suffix == ".jsonl": + _parse_jsonl(text, messages, metadata, parse_errors, max_messages=max_messages) + elif suffix == ".json": + _parse_json(text, messages, metadata, parse_errors, max_messages=max_messages) + else: + _parse_markdown(text, messages, parse_errors, max_messages=max_messages) + source_session_id = _safe_session_id(metadata.get("session_id") or metadata.get("id") or safe_path.stem) + session_id = candidate_id + conversation_id = f"codex-{candidate_id}" + for message in messages: + message["message_id"] = f"{candidate_id}:{message['message_id']}" + title = _clean_text(metadata.get("title") or safe_path.stem, 500) + return { + "workspace_id": scope.workspace_id, + "candidate_id": candidate_id, + "session_id": session_id, + "source_session_id": source_session_id, + "conversation_id": conversation_id, + "relative_path": relative, + "source_kind": "codex", + "title": title, + "summary": f"{len(messages)} parsed messages from {safe_path.name}", + "message_count": len(messages), + "messages": messages, + "parse_errors": parse_errors, + "parse_error_count": len(parse_errors), + "fatal_error": None, + "file_size": stat.st_size, + "file_mtime_ns": stat.st_mtime_ns, + "encoding": encoding, + } + + +def _resolve_roots(scope: WorkspaceScope, roots: list[str]) -> list[Path]: + resolved: list[Path] = [] + for raw in roots: + if not isinstance(raw, str) or not raw.strip(): + raise CodexSessionError("Scan roots must be non-empty relative paths.") + candidate = Path(raw) + if candidate.is_absolute() or ".." in candidate.parts: + raise CodexSessionError("Absolute paths and '..' are not allowed in scan roots.") + try: + path = (scope.workspace_root / candidate).resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise CodexSessionError(f"Scan root cannot be resolved: {exc}") from exc + _ensure_within(scope.workspace_root, path) + if path.is_symlink() or not path.is_dir(): + raise CodexSessionError("Scan root must be a real directory inside the Workspace.") + resolved.append(path) + return list(dict.fromkeys(resolved)) + + +def _iter_candidate_files(scope: WorkspaceScope, root: Path, *, max_depth: int) -> Iterator[Path]: + stack: list[tuple[Path, int]] = [(root, 0)] + while stack: + directory, depth = stack.pop() + try: + entries = sorted(os.scandir(directory), key=lambda entry: entry.name.lower()) + except OSError: + raise + for entry in entries: + entry_path = Path(entry.path) + try: + if entry.is_symlink(): + continue + resolved = entry_path.resolve(strict=True) + _ensure_within(scope.workspace_root, resolved) + if entry.is_dir(follow_symlinks=False): + if depth < max_depth: + stack.append((resolved, depth + 1)) + elif entry.is_file(follow_symlinks=False) and resolved.suffix.lower() in SUPPORTED_SUFFIXES: + yield resolved + except (OSError, RuntimeError, CodexSessionError): + continue + + +def _safe_file(scope: WorkspaceScope, path: Path) -> Path: + if path.is_absolute(): + candidate = path + else: + candidate = scope.workspace_root / path + try: + resolved = candidate.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise CodexSessionError(f"Session file cannot be resolved: {exc}") from exc + _ensure_within(scope.workspace_root, resolved) + if resolved.is_symlink() or not resolved.is_file(): + raise CodexSessionError("Session path must be a real file inside the Workspace.") + return resolved + + +def _ensure_within(root: Path, path: Path) -> None: + try: + path.relative_to(root) + except ValueError as exc: + raise CodexSessionError("Session path escapes the registered Workspace.") from exc + + +def _decode_text(raw: bytes) -> tuple[str, str, str | None]: + attempts: list[tuple[str, str]] = [] + if raw.startswith((b"\xff\xfe", b"\xfe\xff")): + attempts.append(("utf-16", "utf-16")) + if raw.startswith(b"\xef\xbb\xbf"): + attempts.append(("utf-8-sig", "utf-8-sig")) + attempts.extend((("utf-8", "utf-8"), ("gb18030", "gb18030"))) + seen: set[str] = set() + for codec, label in attempts: + if codec in seen: + continue + seen.add(codec) + try: + return raw.decode(codec), label, None + except UnicodeDecodeError: + continue + return "", "unknown", "File is not valid UTF-8, UTF-16, or GB18030 text." + + +def _parse_jsonl( + text: str, + messages: list[dict[str, Any]], + metadata: dict[str, Any], + errors: list[dict[str, Any]], + *, + max_messages: int, +) -> None: + for line_number, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + errors.append({"line": line_number, "code": "invalid_json", "message": "Line is not complete valid JSON."}) + continue + _consume_record(record, messages, metadata, errors, line_number=line_number, max_messages=max_messages) + + +def _parse_json( + text: str, + messages: list[dict[str, Any]], + metadata: dict[str, Any], + errors: list[dict[str, Any]], + *, + max_messages: int, +) -> None: + try: + document = json.loads(text) + except json.JSONDecodeError: + errors.append({"line": 1, "code": "invalid_json", "message": "Document is not complete valid JSON."}) + return + records = document if isinstance(document, list) else document.get("messages", []) if isinstance(document, dict) else [] + if isinstance(document, dict): + metadata.update({key: document.get(key) for key in ("id", "session_id", "title") if document.get(key) is not None}) + if not isinstance(records, list): + errors.append({"line": 1, "code": "invalid_shape", "message": "JSON messages field must be a list."}) + return + for index, record in enumerate(records, start=1): + _consume_record(record, messages, metadata, errors, line_number=index, max_messages=max_messages) + + +def _parse_markdown(text: str, messages: list[dict[str, Any]], errors: list[dict[str, Any]], *, max_messages: int) -> None: + blocks = [block.strip() for block in text.replace("\r\n", "\n").split("\n\n") if block.strip()] + for index, block in enumerate(blocks, start=1): + if len(messages) >= max_messages: + errors.append({"line": index, "code": "message_limit", "message": "Message limit reached."}) + break + role = "unknown" + lowered = block.lower() + for candidate in ("user", "assistant", "system", "developer", "tool"): + if lowered.startswith(candidate + ":"): + role = candidate + block = block.split(":", 1)[1].lstrip() + break + messages.append({"message_id": f"md-{index}", "role": role, "content": block, "timestamp": None, "source": "codex-markdown"}) + + +def _consume_record( + record: Any, + messages: list[dict[str, Any]], + metadata: dict[str, Any], + errors: list[dict[str, Any]], + *, + line_number: int, + max_messages: int, +) -> None: + if not isinstance(record, dict): + errors.append({"line": line_number, "code": "invalid_record", "message": "Record must be a JSON object."}) + return + record_type = str(record.get("type") or "") + raw_payload = record.get("payload") + payload: dict[str, Any] = raw_payload if isinstance(raw_payload, dict) else {} + if record_type in {"session_meta", "session_metadata"}: + metadata.update({key: payload.get(key) for key in ("id", "session_id", "title", "cwd") if payload.get(key) is not None}) + return + role: str | None = None + content: Any = None + message_id: Any = None + if record_type in {"user_message", "assistant_message", "system_message"}: + role = record_type.removesuffix("_message") + content = record.get("message", record.get("content")) + message_id = record.get("id") + elif record_type in {"response_item", "message"}: + source = payload if payload else record + if source.get("type") == "message" or record_type == "message": + role = str(source.get("role") or "unknown") + content = source.get("content") + message_id = source.get("id") + elif "role" in record and "content" in record: + role = str(record.get("role") or "unknown") + content = record.get("content") + message_id = record.get("id") or record.get("message_id") + if role is None: + return + if len(messages) >= max_messages: + if not any(item.get("code") == "message_limit" for item in errors): + errors.append({"line": line_number, "code": "message_limit", "message": "Message limit reached."}) + return + text = _content_text(content) + if not text: + return + normalized_role = role.lower() + if normalized_role not in {"user", "assistant", "system", "developer", "tool"}: + normalized_role = "unknown" + messages.append( + { + "message_id": _safe_session_id(str(message_id or f"line-{line_number}")), + "role": normalized_role, + "content": text, + "timestamp": record.get("timestamp") or payload.get("timestamp"), + "source": "codex-session", + } + ) + + +def _content_text(value: Any) -> str: + if isinstance(value, str): + return value.encode("utf-8", errors="replace").decode("utf-8", errors="replace") + if isinstance(value, list): + parts: list[str] = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") or item.get("content") or item.get("output_text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + if isinstance(value, dict): + text = value.get("text") or value.get("content") + return text if isinstance(text, str) else "" + return "" + + +def _candidate_id(workspace_id: str, relative_path: str) -> str: + digest = hashlib.sha256(f"{workspace_id}\0{relative_path}".encode("utf-8")).hexdigest()[:24] + return f"cand-{digest}" + + +def _safe_session_id(value: str) -> str: + cleaned = "".join(char if char.isalnum() or char in "-._~" else "-" for char in value.strip()) + return (cleaned.strip("-") or hashlib.sha256(value.encode("utf-8")).hexdigest()[:16])[:200] + + +def _clean_text(value: Any, limit: int) -> str: + return str(value or "").encode("utf-8", errors="replace").decode("utf-8", errors="replace")[:limit] + + +def _preview(parsed: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in parsed.items() if key != "messages"} + + +def _base_result( + scope: WorkspaceScope, + relative: str, + candidate_id: str, + *, + fatal_error: str, + parse_errors: list[dict[str, Any]], + file_size: int = 0, + file_mtime_ns: int = 0, +) -> dict[str, Any]: + source_session_id = _safe_session_id(Path(relative).stem) + return { + "workspace_id": scope.workspace_id, + "candidate_id": candidate_id, + "session_id": candidate_id, + "source_session_id": source_session_id, + "conversation_id": f"codex-{candidate_id}", + "relative_path": relative, + "source_kind": "codex", + "title": Path(relative).name, + "summary": "Session could not be parsed.", + "message_count": 0, + "messages": [], + "parse_errors": parse_errors, + "parse_error_count": len(parse_errors), + "fatal_error": fatal_error, + "file_size": file_size, + "file_mtime_ns": file_mtime_ns, + "encoding": None, + } + + +def _file_error_candidate( + scope: WorkspaceScope, + path: Path, + code: str, + *, + file_size: int = 0, + file_mtime_ns: int = 0, +) -> dict[str, Any]: + relative = path.relative_to(scope.workspace_root).as_posix() + return _preview( + _base_result( + scope, + relative, + _candidate_id(scope.workspace_id, relative), + fatal_error=code, + parse_errors=[_error(code, "Session file could not be processed.")], + file_size=file_size, + file_mtime_ns=file_mtime_ns, + ) + ) + + +def _error(code: str, message: str) -> dict[str, Any]: + return {"code": code, "message": message} + + +__all__ = [ + "CodexSessionError", + "CodexSessionScanner", + "ScanPolicy", + "parse_codex_session_file", +] diff --git a/coding_tools_mcp/oauth.py b/coding_tools_mcp/oauth.py index ae4c885..304d532 100644 --- a/coding_tools_mcp/oauth.py +++ b/coding_tools_mcp/oauth.py @@ -7,20 +7,31 @@ import threading import time import urllib.parse -from dataclasses import dataclass, field +import uuid +from dataclasses import dataclass, field, replace from typing import Any import jwt +from .oauth_store import ( + OAuthAuthorizationStore, + OAuthStoreError, + RefreshTokenClientMismatchError, +) +from .secret_vault import SecretVault, SecretVaultError + OAUTH_CODE_TTL_SECONDS = 300 OAUTH_TOKEN_TTL_SECONDS = 24 * 60 * 60 OAUTH_MAX_BODY_BYTES = 8_192 OAUTH_GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code" -# Advertised in AS metadata and used to narrow DCR requests. The token endpoint -# implements authorization_code only — adding an entry here requires a matching -# branch in handle_oauth_token, not just a wider check. -OAUTH_GRANT_TYPES_SUPPORTED = (OAUTH_GRANT_TYPE_AUTHORIZATION_CODE,) +OAUTH_GRANT_TYPE_REFRESH_TOKEN = "refresh_token" +# Shared by AS metadata, DCR narrowing, and token-endpoint dispatch. A grant +# type belongs here only after its endpoint branch and focused tests are complete. +OAUTH_GRANT_TYPES_SUPPORTED = ( + OAUTH_GRANT_TYPE_AUTHORIZATION_CODE, + OAUTH_GRANT_TYPE_REFRESH_TOKEN, +) OAUTH_RESPONSE_TYPES_SUPPORTED = ("code",) MAX_REDIRECT_URIS = 10 MAX_REGISTERED_CLIENTS = 1_024 @@ -34,6 +45,7 @@ class OAuthClient: token_endpoint_auth_method: str client_name: str | None = None secret_digest: str | None = None + workspace_id: str | None = None issued_at: int = field(default_factory=lambda: int(time.time())) def accepts_redirect(self, redirect_uri: str) -> bool: @@ -47,6 +59,27 @@ def verifies_secret(self, secret: str) -> bool: return secrets.compare_digest(self.secret_digest, _secret_digest(secret)) +@dataclass(frozen=True) +class OAuthIdentity: + client_id: str + grant_id: str + workspace_id: str + jti: str + + +@dataclass(frozen=True) +class AccessTokenIssue: + token: str + jti: str + client_id: str + grant_id: str + signing_kid: str + scopes: str + issued_at: int + expires_at: int + token_mode: str + + class OAuthClientRegistry: """Thread-safe RFC 7591 client registry for one server process.""" @@ -73,26 +106,7 @@ def add_preregistered( self._clients[client_id] = client def register(self, metadata: dict[str, Any]) -> dict[str, Any]: - redirects = validate_redirect_uris(metadata.get("redirect_uris")) - requested_grant_types = metadata.get("grant_types", list(OAUTH_GRANT_TYPES_SUPPORTED)) - requested_response_types = metadata.get("response_types", list(OAUTH_RESPONSE_TYPES_SUPPORTED)) - if not isinstance(requested_grant_types, list) or not all( - isinstance(item, str) for item in requested_grant_types - ): - raise ValueError("grant_types must be an array of strings") - grant_types = tuple(item for item in OAUTH_GRANT_TYPES_SUPPORTED if item in requested_grant_types) - if not grant_types: - raise ValueError("grant_types must include at least one supported value") - if not isinstance(requested_response_types, list) or not all( - isinstance(item, str) for item in requested_response_types - ): - raise ValueError("response_types must be an array of strings") - response_types = tuple(item for item in OAUTH_RESPONSE_TYPES_SUPPORTED if item in requested_response_types) - if not response_types: - raise ValueError("response_types must include at least one supported value") - method = str(metadata.get("token_endpoint_auth_method") or "none") - if method not in {"none", "client_secret_post", "client_secret_basic"}: - raise ValueError("unsupported token_endpoint_auth_method") + redirects, grant_types, response_types, method, client_name = _validated_registration(metadata) with self._lock: if len(self._clients) >= MAX_REGISTERED_CLIENTS: raise ValueError("dynamic client registration limit reached") @@ -104,24 +118,11 @@ def register(self, metadata: dict[str, Any]) -> dict[str, Any]: client_id=client_id, redirect_uris=redirects, token_endpoint_auth_method=method, - client_name=_optional_text(metadata.get("client_name"), 200), + client_name=client_name, secret_digest=_secret_digest(client_secret) if client_secret is not None else None, ) self._clients[client_id] = client - response: dict[str, Any] = { - "client_id": client.client_id, - "client_id_issued_at": client.issued_at, - "redirect_uris": list(client.redirect_uris), - "grant_types": list(grant_types), - "response_types": list(response_types), - "token_endpoint_auth_method": client.token_endpoint_auth_method, - } - if client.client_name: - response["client_name"] = client.client_name - if client_secret is not None: - response["client_secret"] = client_secret - response["client_secret_expires_at"] = 0 - return response + return _registration_response(client, grant_types, response_types, client_secret) def get(self, client_id: str) -> OAuthClient | None: with self._lock: @@ -140,6 +141,102 @@ def authenticates(self, client_id: str, client_secret: str, auth_method: str) -> ) +class PersistentOAuthClientRegistry(OAuthClientRegistry): + """Store-backed registry preserving the upstream registry interface. + + Store errors propagate so callers can fail closed instead of silently + falling back to an in-memory registry. + """ + + def __init__( + self, + store: OAuthAuthorizationStore, + *, + registration_workspace_id: str | None = "default", + ) -> None: + self.store = store + self.registration_workspace_id = registration_workspace_id + + def add_preregistered( + self, + client_id: str, + redirect_uris: tuple[str, ...], + *, + client_secret: str | None, + workspace_id: str | None = None, + ) -> None: + redirects = validate_redirect_uris(list(redirect_uris)) + method = "client_secret_post" if client_secret is not None else "none" + resolved_workspace_id = workspace_id or self.registration_workspace_id + self.store.upsert_client( + client_id, + display_name=client_id, + scopes="mcp", + redirect_uris=redirects, + client_type="confidential" if client_secret is not None else "public_pkce", + token_endpoint_auth_method=method, + client_secret_digest=( + _secret_digest(client_secret) if client_secret is not None else None + ), + workspace_id=resolved_workspace_id, + ) + + def register(self, metadata: dict[str, Any]) -> dict[str, Any]: + redirects, grant_types, response_types, method, client_name = _validated_registration(metadata) + if len(self.store.list_clients()) >= MAX_REGISTERED_CLIENTS: + raise ValueError("dynamic client registration limit reached") + client_id = secrets.token_urlsafe(24) + while self.store.get_client(client_id) is not None: + client_id = secrets.token_urlsafe(24) + client_secret = secrets.token_urlsafe(32) if method != "none" else None + client = OAuthClient( + client_id=client_id, + redirect_uris=redirects, + token_endpoint_auth_method=method, + client_name=client_name, + secret_digest=_secret_digest(client_secret) if client_secret is not None else None, + ) + self.store.upsert_client( + client.client_id, + display_name=client.client_name or client.client_id, + scopes="mcp", + redirect_uris=client.redirect_uris, + client_type="confidential" if client_secret is not None else "public_pkce", + token_endpoint_auth_method=client.token_endpoint_auth_method, + client_secret_digest=client.secret_digest, + workspace_id=self.registration_workspace_id, + ) + return _registration_response(client, grant_types, response_types, client_secret) + + def get(self, client_id: str) -> OAuthClient | None: + record = self.store.get_client(client_id) + if record is None or not bool(record.get("enabled")) or record.get("revoked_at") is not None: + return None + redirects = record.get("redirect_uris") + if not isinstance(redirects, list) or not all(isinstance(item, str) for item in redirects): + return None + method = record.get("token_endpoint_auth_method") + if method not in {"none", "client_secret_post", "client_secret_basic"}: + return None + digest = record.get("client_secret_digest") + if digest is not None and not isinstance(digest, str): + return None + created_at = record.get("created_at") + return OAuthClient( + client_id=client_id, + redirect_uris=tuple(redirects), + token_endpoint_auth_method=method, + client_name=str(record.get("display_name") or client_id), + secret_digest=digest, + workspace_id=( + str(record["workspace_id"]) + if isinstance(record.get("workspace_id"), str) and record["workspace_id"] + else None + ), + issued_at=int(created_at) if isinstance(created_at, (int, float)) else int(time.time()), + ) + + @dataclass(frozen=True) class OAuthConfig: password: str @@ -147,10 +244,153 @@ class OAuthConfig: token_secret: bytes token_ttl: int = OAUTH_TOKEN_TTL_SECONDS registry: OAuthClientRegistry = field(default_factory=OAuthClientRegistry) + store: OAuthAuthorizationStore | None = None + secret_vault: SecretVault | None = None + refresh_token_ttl: int = 60 * 60 * 24 * 90 + signing_kid: str | None = None + signing_keys: dict[str, bytes] = field(default_factory=dict) pending_codes: dict[str, dict[str, Any]] = field(default_factory=dict) pending_codes_lock: threading.Lock = field(default_factory=threading.Lock) +class OAuthServiceError(RuntimeError): + """Persistent OAuth state cannot safely complete the requested operation.""" + + +def create_authorization_grant( + config: OAuthConfig, + *, + client_id: str, + redirect_uri: str, + scopes: str, +) -> str: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + client = config.registry.get(client_id) + if client is None or not client.accepts_redirect(redirect_uri): + raise OAuthServiceError("OAuth client or redirect URI is not active.") + try: + return config.store.create_grant(client_id, scopes) + except (OAuthStoreError, ValueError) as exc: + raise OAuthServiceError("OAuth authorization store is unavailable.") from exc + + +class OAuthClientAuthenticationError(OAuthServiceError): + pass + + +class OAuthInvalidGrantError(OAuthServiceError): + pass + + +def _active_grant( + config: OAuthConfig, + *, + grant_id: str, + client_id: str, +) -> dict[str, Any]: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + try: + grant = config.store.get_grant(grant_id) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth authorization store is unavailable.") from exc + if ( + grant is None + or grant.get("client_id") != client_id + or not bool(grant.get("enabled")) + or grant.get("revoked_at") is not None + ): + raise OAuthInvalidGrantError("OAuth grant is not active.") + return grant + + +def issue_refresh_token( + config: OAuthConfig, + *, + grant_id: str, + client_id: str, + scopes: str, +) -> str: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + _active_grant(config, grant_id=grant_id, client_id=client_id) + try: + _family_id, token = config.store.issue_refresh_token( + grant_id, + client_id, + scopes, + expires_at=time.time() + config.refresh_token_ttl, + ) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth refresh-token state could not be persisted.") from exc + return token + + +def exchange_refresh_token( + config: OAuthConfig, + *, + refresh_token: str, + client_id: str, + client_secret: str, + auth_method: str, + server_url: str, +) -> dict[str, Any]: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + try: + authenticated = config.registry.authenticates( + client_id, + client_secret, + auth_method, + ) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth client registry is unavailable.") from exc + if not authenticated: + raise OAuthClientAuthenticationError("OAuth client authentication failed.") + try: + binding = config.store.refresh_token_binding(refresh_token) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth refresh-token store is unavailable.") from exc + if binding is None: + raise OAuthInvalidGrantError("Refresh token is invalid, expired, or reused.") + if not secrets.compare_digest(binding.client_id, client_id): + raise OAuthClientAuthenticationError("Refresh token client mismatch.") + + access_issue = _prepare_access_token( + config, + server_url, + client_id=binding.client_id, + grant_id=binding.grant_id, + scope=binding.scopes, + ) + try: + rotated = config.store.rotate_refresh_token_and_record_access_token( + refresh_token, + expected_client_id=client_id, + refresh_expires_at=time.time() + config.refresh_token_ttl, + access_jti=access_issue.jti, + access_signing_kid=access_issue.signing_kid, + access_scopes=access_issue.scopes, + access_issued_at=access_issue.issued_at, + access_expires_at=access_issue.expires_at, + token_mode=access_issue.token_mode, + ) + except RefreshTokenClientMismatchError as exc: + raise OAuthClientAuthenticationError("Refresh token client mismatch.") from exc + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth refresh-token exchange could not be persisted.") from exc + if rotated is None: + raise OAuthInvalidGrantError("Refresh token is invalid, expired, or reused.") + return { + "access_token": access_issue.token, + "token_type": "Bearer", + "expires_in": config.token_ttl, + "scope": rotated.scopes, + "refresh_token": rotated.token, + } + + def validate_redirect_uris(value: Any) -> tuple[str, ...]: if not isinstance(value, list) or not value or len(value) > MAX_REDIRECT_URIS: raise ValueError(f"redirect_uris must contain between 1 and {MAX_REDIRECT_URIS} entries") @@ -174,6 +414,54 @@ def validate_redirect_uris(value: Any) -> tuple[str, ...]: return tuple(redirects) +def _validated_registration( + metadata: dict[str, Any], +) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...], str, str | None]: + redirects = validate_redirect_uris(metadata.get("redirect_uris")) + requested_grant_types = metadata.get("grant_types", list(OAUTH_GRANT_TYPES_SUPPORTED)) + requested_response_types = metadata.get("response_types", list(OAUTH_RESPONSE_TYPES_SUPPORTED)) + if not isinstance(requested_grant_types, list) or not all( + isinstance(item, str) for item in requested_grant_types + ): + raise ValueError("grant_types must be an array of strings") + grant_types = tuple(item for item in OAUTH_GRANT_TYPES_SUPPORTED if item in requested_grant_types) + if not grant_types: + raise ValueError("grant_types must include at least one supported value") + if not isinstance(requested_response_types, list) or not all( + isinstance(item, str) for item in requested_response_types + ): + raise ValueError("response_types must be an array of strings") + response_types = tuple(item for item in OAUTH_RESPONSE_TYPES_SUPPORTED if item in requested_response_types) + if not response_types: + raise ValueError("response_types must include at least one supported value") + method = str(metadata.get("token_endpoint_auth_method") or "none") + if method not in {"none", "client_secret_post", "client_secret_basic"}: + raise ValueError("unsupported token_endpoint_auth_method") + return redirects, grant_types, response_types, method, _optional_text(metadata.get("client_name"), 200) + + +def _registration_response( + client: OAuthClient, + grant_types: tuple[str, ...], + response_types: tuple[str, ...], + client_secret: str | None, +) -> dict[str, Any]: + response: dict[str, Any] = { + "client_id": client.client_id, + "client_id_issued_at": client.issued_at, + "redirect_uris": list(client.redirect_uris), + "grant_types": list(grant_types), + "response_types": list(response_types), + "token_endpoint_auth_method": client.token_endpoint_auth_method, + } + if client.client_name: + response["client_name"] = client.client_name + if client_secret is not None: + response["client_secret"] = client_secret + response["client_secret_expires_at"] = 0 + return response + + def verify_pkce(code_verifier: str, code_challenge: str) -> bool: if not re.fullmatch(r"[A-Za-z0-9\-._~]{43,128}", code_verifier): return False @@ -186,36 +474,265 @@ def valid_pkce_challenge(code_challenge: str) -> bool: return re.fullmatch(r"[A-Za-z0-9_-]{43}", code_challenge) is not None -def create_access_token(config: OAuthConfig, server_url: str, *, client_id: str) -> str: +def signing_key_id(secret: bytes) -> str: + return f"key-{hashlib.sha256(secret).hexdigest()[:16]}" + + +def signing_key_secret_ref(kid: str) -> str: + return f"oauth/signing/{kid}" + + +def initialize_signing_key_ring( + store: OAuthAuthorizationStore, + vault: SecretVault, + initial_secret: bytes, + *, + legacy_secret_ref: str, +) -> tuple[str, bytes, dict[str, bytes]]: + if not vault.enabled(): + raise OAuthServiceError("OAuth Secret Vault is not enabled.") + records = store.list_signing_keys() + initial_kid = signing_key_id(initial_secret) + if not records: + reference = signing_key_secret_ref(initial_kid) + vault.set_secret(reference, initial_secret.hex()) + store.register_signing_key( + initial_kid, + hashlib.sha256(initial_secret).hexdigest(), + secret_ref=reference, + ) + records = store.list_signing_keys() + + keys: dict[str, bytes] = {} + active: list[tuple[str, bytes]] = [] + for record in records: + status = record.get("status") + if status not in {"active", "retired"}: + continue + raw_kid = record.get("kid") + raw_reference = record.get("secret_ref") + if not isinstance(raw_kid, str) or not raw_kid: + raise OAuthServiceError("OAuth signing-key metadata is invalid.") + kid = raw_kid + if not isinstance(raw_reference, str) or not raw_reference: + raise OAuthServiceError(f"OAuth signing key {kid!r} has no Vault reference.") + reference = raw_reference + try: + secret = bytes.fromhex(vault.get_secret(reference)) + except (ValueError, SecretVaultError) as exc: + raise OAuthServiceError( + f"OAuth signing key {kid!r} cannot be loaded from Secret Vault." + ) from exc + fingerprint = hashlib.sha256(secret).hexdigest() + if record.get("fingerprint") != fingerprint or signing_key_id(secret) != kid: + raise OAuthServiceError(f"OAuth signing key {kid!r} metadata does not match its secret.") + if reference == legacy_secret_ref: + migrated_ref = signing_key_secret_ref(kid) + vault.set_secret(migrated_ref, secret.hex()) + store.register_signing_key( + kid, + fingerprint, + secret_ref=migrated_ref, + active=status == "active", + ) + keys[kid] = secret + if status == "active": + active.append((kid, secret)) + if len(active) != 1: + raise OAuthServiceError("OAuth signing-key ring must contain exactly one active key.") + active_kid, active_secret = active[0] + return active_kid, active_secret, keys + + +def rotate_signing_key(config: OAuthConfig) -> OAuthConfig: + if config.store is None or config.secret_vault is None: + raise OAuthServiceError("OAuth signing-key persistence is not configured.") + if not config.secret_vault.enabled(): + raise OAuthServiceError("OAuth Secret Vault is not enabled.") + secret = secrets.token_bytes(32) + kid = signing_key_id(secret) + reference = signing_key_secret_ref(kid) + try: + config.secret_vault.set_secret(reference, secret.hex()) + config.store.register_signing_key( + kid, + hashlib.sha256(secret).hexdigest(), + secret_ref=reference, + ) + except (OAuthStoreError, SecretVaultError, ValueError) as exc: + raise OAuthServiceError("OAuth signing-key rotation failed.") from exc + keys = dict(config.signing_keys) + keys[kid] = secret + return replace( + config, + token_secret=secret, + signing_kid=kid, + signing_keys=keys, + ) + + +def revoke_signing_key(config: OAuthConfig, kid: str) -> bool: + if config.store is None: + raise OAuthServiceError("OAuth signing-key persistence is not configured.") + try: + return config.store.revoke_signing_key(kid) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth signing-key revocation failed.") from exc + + +def oauth_signing_kid(config: OAuthConfig) -> str: + return config.signing_kid or signing_key_id(config.token_secret) + + +def _oauth_signing_key(config: OAuthConfig, kid: str) -> bytes | None: + if kid in config.signing_keys: + return config.signing_keys[kid] + if secrets.compare_digest(kid, oauth_signing_kid(config)): + return config.token_secret + return None + + +def _prepare_access_token( + config: OAuthConfig, + server_url: str, + *, + client_id: str, + grant_id: str, + scope: str = "mcp", + token_mode: str = "standard", +) -> AccessTokenIssue: now = int(time.time()) - return jwt.encode( + expires_at = now + config.token_ttl + jti = str(uuid.uuid4()) + kid = oauth_signing_kid(config) + key = _oauth_signing_key(config, kid) + if key is None: + raise OAuthServiceError("OAuth signing key is unavailable.") + token = jwt.encode( { "iss": server_url, "aud": server_url, - "sub": client_id, + "sub": grant_id, "client_id": client_id, + "grant_id": grant_id, "iat": now, - "exp": now + config.token_ttl, - "scope": "mcp", + "exp": expires_at, + "scope": scope, + "jti": jti, }, - config.token_secret, + key, algorithm="HS256", + headers={"kid": kid}, + ) + return AccessTokenIssue( + token=token, + jti=jti, + client_id=client_id, + grant_id=grant_id, + signing_kid=kid, + scopes=scope, + issued_at=now, + expires_at=expires_at, + token_mode=token_mode, ) -def validate_access_token(token: str, config: OAuthConfig, server_url: str) -> bool: +def create_access_token( + config: OAuthConfig, + server_url: str, + *, + client_id: str, + grant_id: str, + scope: str = "mcp", + token_mode: str = "standard", +) -> str: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + issue = _prepare_access_token( + config, + server_url, + client_id=client_id, + grant_id=grant_id, + scope=scope, + token_mode=token_mode, + ) + try: + config.store.record_access_token( + issue.jti, + issue.grant_id, + issue.client_id, + issue.signing_kid, + issue.scopes, + issued_at=issue.issued_at, + expires_at=issue.expires_at, + token_mode=issue.token_mode, + ) + except OAuthStoreError as exc: + raise OAuthServiceError("OAuth access-token state could not be persisted.") from exc + return issue.token + + +def authenticate_access_token( + token: str, + config: OAuthConfig, + server_url: str, +) -> OAuthIdentity | None: + if config.store is None: + return None try: + header = jwt.get_unverified_header(token) + kid = header.get("kid") + if not isinstance(kid, str): + return None + key = _oauth_signing_key(config, kid) + if key is None: + return None claims = jwt.decode( token, - config.token_secret, + key, algorithms=["HS256"], audience=server_url, issuer=server_url, + options={ + "require": [ + "iss", + "aud", + "client_id", + "grant_id", + "iat", + "exp", + "jti", + ] + }, ) except jwt.PyJWTError: - return False + return None client_id = claims.get("client_id") - return isinstance(client_id, str) and config.registry.get(client_id) is not None + grant_id = claims.get("grant_id") + jti = claims.get("jti") + if not isinstance(client_id, str) or not client_id: + return None + if not isinstance(grant_id, str) or not grant_id: + return None + if not isinstance(jti, str) or not jti: + return None + if claims.get("sub") != grant_id: + return None + persisted = config.store.active_access_token_identity(jti) + if persisted is None: + return None + if persisted["client_id"] != client_id or persisted["grant_id"] != grant_id: + return None + return OAuthIdentity( + client_id=client_id, + grant_id=grant_id, + workspace_id=persisted["workspace_id"], + jti=jti, + ) + + +def validate_access_token(token: str, config: OAuthConfig, server_url: str) -> bool: + return authenticate_access_token(token, config, server_url) is not None def _secret_digest(secret: str) -> str: diff --git a/coding_tools_mcp/oauth_store.py b/coding_tools_mcp/oauth_store.py new file mode 100644 index 0000000..7d8b4db --- /dev/null +++ b/coding_tools_mcp/oauth_store.py @@ -0,0 +1,1373 @@ +"""Persistent, metadata-only OAuth authorization state. + +Bearer credentials are deliberately never written to this database. Access +tokens are identified by JWT ``jti`` values and refresh tokens are stored only +as HMAC-SHA256 digests keyed by a server-side pepper. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import secrets +import sqlite3 +import time +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +class OAuthStoreError(RuntimeError): + """OAuth persistence cannot safely serve an authorization decision.""" + + +class RefreshTokenClientMismatchError(OAuthStoreError): + """A refresh token is bound to a different authenticated client.""" + + +@dataclass(frozen=True) +class RefreshTokenResult: + family_id: str + token: str + client_id: str + grant_id: str + scopes: str + + +@dataclass(frozen=True) +class RefreshTokenBinding: + family_id: str + client_id: str + grant_id: str + scopes: str + + +class OAuthAuthorizationStore: + """SQLite-backed authorization metadata with fail-closed helpers.""" + + SCHEMA_VERSION = 4 + + def __init__(self, path: str | Path, *, pepper: bytes) -> None: + if not isinstance(pepper, bytes) or not pepper: + raise ValueError("OAuth refresh-token pepper must not be empty.") + self.path = Path(path).expanduser() + self.pepper = bytes(pepper) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._migrate() + + @contextmanager + def _connection(self, operation: str) -> Iterator[sqlite3.Connection]: + conn: sqlite3.Connection | None = None + try: + conn = sqlite3.connect(self.path, timeout=5, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA busy_timeout = 5000") + yield conn + except sqlite3.Error as exc: + raise OAuthStoreError(f"OAuth authorization database {operation} failed: {exc}") from exc + finally: + if conn is not None: + conn.close() + + @contextmanager + def _transaction( + self, + operation: str, + *, + immediate: bool = False, + ) -> Iterator[sqlite3.Connection]: + with self._connection(operation) as conn: + conn.execute("BEGIN IMMEDIATE" if immediate else "BEGIN") + try: + yield conn + except BaseException: + conn.rollback() + raise + else: + conn.commit() + + def _migrate(self) -> None: + with self._connection("migration setup") as conn: + conn.execute("PRAGMA journal_mode = WAL") + + with self._transaction("migration", immediate=True) as conn: + current = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if current > self.SCHEMA_VERSION: + raise OAuthStoreError( + "OAuth authorization database was created by a newer server version." + ) + if current == 0: + for statement in self._schema_v1_statements(): + conn.execute(statement) + conn.execute("PRAGMA user_version = 1") + current = 1 + if current == 1: + columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_signing_keys)").fetchall() + } + if "secret_ref" not in columns: + conn.execute("ALTER TABLE oauth_signing_keys ADD COLUMN secret_ref TEXT") + conn.execute("PRAGMA user_version = 2") + current = 2 + if current == 2: + client_columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_clients)").fetchall() + } + if "redirect_uris_json" not in client_columns: + conn.execute("ALTER TABLE oauth_clients ADD COLUMN redirect_uris_json TEXT") + if "token_endpoint_auth_method" not in client_columns: + conn.execute( + "ALTER TABLE oauth_clients ADD COLUMN token_endpoint_auth_method TEXT NOT NULL DEFAULT 'none'" + ) + if "client_secret_digest" not in client_columns: + conn.execute("ALTER TABLE oauth_clients ADD COLUMN client_secret_digest TEXT") + rows = conn.execute( + "SELECT client_id, redirect_uri FROM oauth_clients WHERE redirect_uris_json IS NULL" + ).fetchall() + for row in rows: + conn.execute( + "UPDATE oauth_clients SET redirect_uris_json=? WHERE client_id=?", + (json.dumps([row["redirect_uri"]]), row["client_id"]), + ) + conn.execute("PRAGMA user_version = 3") + current = 3 + if current == 3: + client_columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_clients)").fetchall() + } + grant_columns = { + str(row["name"]) + for row in conn.execute("PRAGMA table_info(oauth_grants)").fetchall() + } + if "workspace_id" not in client_columns: + conn.execute("ALTER TABLE oauth_clients ADD COLUMN workspace_id TEXT") + if "workspace_id" not in grant_columns: + conn.execute("ALTER TABLE oauth_grants ADD COLUMN workspace_id TEXT") + conn.execute(f"PRAGMA user_version = {self.SCHEMA_VERSION}") + + @classmethod + def _schema_v1_statements(cls) -> tuple[str, ...]: + return ( + """ + CREATE TABLE oauth_clients ( + client_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + client_type TEXT NOT NULL DEFAULT 'public_pkce', + redirect_uri TEXT NOT NULL, + allowed_scopes TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + revoked_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + first_authorized_at REAL, + last_seen_at REAL + ) + """, + """ + CREATE TABLE oauth_grants ( + grant_id TEXT PRIMARY KEY, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id), + scopes TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + revoked_at REAL, + revoke_reason TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + last_used_at REAL + ) + """, + "CREATE INDEX oauth_grants_client_idx ON oauth_grants(client_id)", + """ + CREATE TABLE oauth_signing_keys ( + kid TEXT PRIMARY KEY, + algorithm TEXT NOT NULL, + fingerprint TEXT NOT NULL, + status TEXT NOT NULL, + created_at REAL NOT NULL, + activated_at REAL, + retired_at REAL, + revoked_at REAL + ) + """, + """ + CREATE TABLE oauth_access_tokens ( + jti TEXT PRIMARY KEY, + grant_id TEXT NOT NULL REFERENCES oauth_grants(grant_id), + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id), + signing_kid TEXT NOT NULL REFERENCES oauth_signing_keys(kid), + scopes TEXT NOT NULL, + token_mode TEXT NOT NULL DEFAULT 'standard', + issued_at REAL NOT NULL, + expires_at REAL NOT NULL, + last_used_at REAL, + revoked_at REAL, + revoke_reason TEXT + ) + """, + "CREATE INDEX oauth_access_tokens_grant_idx ON oauth_access_tokens(grant_id)", + "CREATE INDEX oauth_access_tokens_expires_idx ON oauth_access_tokens(expires_at)", + """ + CREATE TABLE oauth_refresh_token_families ( + family_id TEXT PRIMARY KEY, + grant_id TEXT NOT NULL REFERENCES oauth_grants(grant_id), + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id), + scopes TEXT NOT NULL, + created_at REAL NOT NULL, + expires_at REAL NOT NULL, + last_used_at REAL, + revoked_at REAL, + revoke_reason TEXT + ) + """, + """ + CREATE TABLE oauth_refresh_tokens ( + token_id TEXT PRIMARY KEY, + family_id TEXT NOT NULL REFERENCES oauth_refresh_token_families(family_id), + token_hash TEXT NOT NULL UNIQUE, + issued_at REAL NOT NULL, + expires_at REAL NOT NULL, + used_at REAL, + revoked_at REAL, + replacement_token_id TEXT REFERENCES oauth_refresh_tokens(token_id), + reuse_detected_at REAL + ) + """, + "CREATE INDEX oauth_refresh_tokens_family_idx ON oauth_refresh_tokens(family_id)", + """ + CREATE TABLE oauth_audit_events ( + event_id TEXT PRIMARY KEY, + timestamp REAL NOT NULL, + event_type TEXT NOT NULL, + client_id TEXT, + grant_id TEXT, + token_id TEXT, + key_id TEXT, + actor_kind TEXT NOT NULL, + details_json TEXT NOT NULL + ) + """, + "CREATE INDEX oauth_audit_events_time_idx ON oauth_audit_events(timestamp DESC)", + ) + + @staticmethod + def validate_client_id(client_id: str) -> str: + if not isinstance(client_id, str) or not 1 <= len(client_id) <= 128: + raise ValueError("OAuth client_id must contain 1-128 characters.") + if not all(char.isalnum() or char in "-._~" for char in client_id): + raise ValueError("OAuth client_id contains unsupported characters.") + return client_id + + @staticmethod + def validate_workspace_id(workspace_id: str) -> str: + if not isinstance(workspace_id, str) or not 1 <= len(workspace_id) <= 128: + raise ValueError("Workspace id must contain 1-128 characters.") + if not all(char.isalnum() or char in "._-" for char in workspace_id): + raise ValueError("Workspace id contains unsupported characters.") + return workspace_id + + @staticmethod + def validate_redirect_uri(value: str) -> str: + from urllib.parse import urlsplit, urlunsplit + + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.fragment: + raise ValueError( + "OAuth redirect_uri must be an absolute http(s) URI without a fragment." + ) + if parsed.username or parsed.password: + raise ValueError("OAuth redirect_uri must not contain user credentials.") + return urlunsplit( + ( + parsed.scheme.lower(), + parsed.netloc.lower(), + parsed.path or "/", + parsed.query, + "", + ) + ) + + def upsert_client( + self, + client_id: str, + *, + display_name: str | None = None, + scopes: str, + redirect_uri: str | None = None, + redirect_uris: tuple[str, ...] | list[str] | None = None, + client_type: str = "public_pkce", + token_endpoint_auth_method: str = "none", + client_secret_digest: str | None = None, + workspace_id: str | None = None, + ) -> None: + client_id = self.validate_client_id(client_id) + if workspace_id is not None: + workspace_id = self.validate_workspace_id(workspace_id) + if redirect_uris is not None and redirect_uri is not None: + raise ValueError("Specify redirect_uri or redirect_uris, not both.") + raw_redirects = list(redirect_uris) if redirect_uris is not None else [redirect_uri] + if not 1 <= len(raw_redirects) <= 10 or any(item is None for item in raw_redirects): + raise ValueError("OAuth clients require between 1 and 10 redirect URIs.") + normalized_redirects = tuple( + self.validate_redirect_uri(str(item)) for item in raw_redirects + ) + if len(set(normalized_redirects)) != len(normalized_redirects): + raise ValueError("OAuth redirect URIs must be unique.") + if token_endpoint_auth_method not in { + "none", + "client_secret_post", + "client_secret_basic", + }: + raise ValueError("Unsupported token_endpoint_auth_method.") + if token_endpoint_auth_method == "none" and client_secret_digest is not None: + raise ValueError("Public OAuth clients must not have a client-secret digest.") + if token_endpoint_auth_method != "none": + if ( + not isinstance(client_secret_digest, str) + or len(client_secret_digest) != 64 + or any( + char not in "0123456789abcdef" + for char in client_secret_digest.lower() + ) + ): + raise ValueError( + "Confidential OAuth clients require a SHA-256 secret digest." + ) + client_secret_digest = client_secret_digest.lower() + redirects_json = json.dumps(list(normalized_redirects), separators=(",", ":")) + now = time.time() + with self._transaction("client upsert", immediate=True) as conn: + existing = conn.execute( + """ + SELECT redirect_uri, redirect_uris_json, enabled, workspace_id + FROM oauth_clients WHERE client_id = ? + """, + (client_id,), + ).fetchone() + existing_redirects = None + if existing is not None: + raw_existing = existing["redirect_uris_json"] + existing_redirects = tuple( + json.loads(raw_existing) + if raw_existing + else [existing["redirect_uri"]] + ) + if ( + existing_redirects is not None + and existing_redirects != normalized_redirects + ): + raise ValueError( + "OAuth redirect URIs do not exactly match the registered client URIs." + ) + if existing is None: + conn.execute( + """ + INSERT INTO oauth_clients( + client_id, display_name, client_type, redirect_uri, + allowed_scopes, created_at, updated_at, first_authorized_at, + redirect_uris_json, token_endpoint_auth_method, + client_secret_digest, workspace_id + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + client_id, + display_name or client_id, + client_type, + normalized_redirects[0], + scopes, + now, + now, + now, + redirects_json, + token_endpoint_auth_method, + client_secret_digest, + workspace_id, + ), + ) + self._audit( + conn, + "client_authorized", + client_id=client_id, + actor_kind="user", + details={ + "redirect_uris": list(normalized_redirects), + "workspace_id": workspace_id, + }, + ) + return + if not bool(existing["enabled"]): + raise OAuthStoreError("OAuth client is disabled.") + conn.execute( + """ + UPDATE oauth_clients + SET display_name=?, client_type=?, allowed_scopes=?, updated_at=?, + redirect_uris_json=?, token_endpoint_auth_method=?, + client_secret_digest=?, workspace_id=COALESCE(?, workspace_id) + WHERE client_id=? + """, + ( + display_name or client_id, + client_type, + scopes, + now, + redirects_json, + token_endpoint_auth_method, + client_secret_digest, + workspace_id, + client_id, + ), + ) + + def get_client(self, client_id: str) -> dict[str, Any] | None: + with self._connection("client query") as conn: + row = conn.execute( + "SELECT * FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + return self._client_payload(row) if row is not None else None + + def set_client_workspace(self, client_id: str, workspace_id: str) -> bool: + client_id = self.validate_client_id(client_id) + workspace_id = self.validate_workspace_id(workspace_id) + with self._transaction("client workspace binding", immediate=True) as conn: + row = conn.execute( + "SELECT enabled, revoked_at, workspace_id FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + if row is None: + return False + if not bool(row["enabled"]) or row["revoked_at"] is not None: + raise OAuthStoreError("OAuth client is not active.") + if row["workspace_id"] == workspace_id: + return True + now = time.time() + conn.execute( + "UPDATE oauth_clients SET workspace_id=?, updated_at=? WHERE client_id=?", + (workspace_id, now, client_id), + ) + self._audit( + conn, + "client_workspace_bound", + client_id=client_id, + actor_kind="admin", + details={"workspace_id": workspace_id}, + ) + return True + + def set_client_enabled( + self, + client_id: str, + enabled: bool, + *, + reason: str = "administrator", + ) -> bool: + with self._transaction("client state update", immediate=True) as conn: + row = conn.execute( + "SELECT enabled, revoked_at FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + if row is None: + return False + if bool(row["enabled"]) is enabled: + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_clients + SET enabled=?, revoked_at=?, updated_at=? + WHERE client_id=? + """, + (1 if enabled else 0, None if enabled else now, now, client_id), + ) + if not enabled: + conn.execute( + """ + UPDATE oauth_grants + SET enabled=0, revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE client_id=? + """, + (now, reason, client_id), + ) + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE client_id=? + """, + (now, reason, client_id), + ) + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE client_id=? + """, + (now, reason, client_id), + ) + self._audit( + conn, + "client_enabled" if enabled else "client_disabled", + client_id=client_id, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def create_grant(self, client_id: str, scopes: str) -> str: + now = time.time() + grant_id = str(uuid.uuid4()) + with self._transaction("grant creation", immediate=True) as conn: + client = conn.execute( + "SELECT enabled, revoked_at, workspace_id FROM oauth_clients WHERE client_id=?", + (client_id,), + ).fetchone() + if ( + client is None + or not bool(client["enabled"]) + or client["revoked_at"] is not None + ): + raise OAuthStoreError("OAuth client is not active.") + workspace_id = client["workspace_id"] + if not isinstance(workspace_id, str) or not workspace_id: + raise OAuthStoreError("OAuth client has no authorized Workspace binding.") + conn.execute( + """ + INSERT INTO oauth_grants( + grant_id, client_id, scopes, workspace_id, created_at, updated_at + ) VALUES(?,?,?,?,?,?) + """, + (grant_id, client_id, scopes, workspace_id, now, now), + ) + self._audit( + conn, + "grant_created", + client_id=client_id, + grant_id=grant_id, + actor_kind="user", + details={"scopes": scopes, "workspace_id": workspace_id}, + ) + return grant_id + + def get_grant(self, grant_id: str) -> dict[str, Any] | None: + with self._connection("grant query") as conn: + row = conn.execute( + "SELECT * FROM oauth_grants WHERE grant_id=?", + (grant_id,), + ).fetchone() + return dict(row) if row is not None else None + + def revoke_grant(self, grant_id: str, *, reason: str = "administrator") -> bool: + with self._transaction("grant revocation", immediate=True) as conn: + row = conn.execute( + "SELECT client_id, revoked_at FROM oauth_grants WHERE grant_id=?", + (grant_id,), + ).fetchone() + if row is None: + return False + if row["revoked_at"] is not None: + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_grants + SET enabled=0, revoked_at=?, revoke_reason=?, updated_at=? + WHERE grant_id=? + """, + (now, reason, now, grant_id), + ) + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE grant_id=? + """, + (now, reason, grant_id), + ) + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,?) + WHERE grant_id=? + """, + (now, reason, grant_id), + ) + self._audit( + conn, + "grant_revoked", + client_id=row["client_id"], + grant_id=grant_id, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def register_signing_key( + self, + kid: str, + fingerprint: str, + *, + secret_ref: str, + algorithm: str = "HS256", + active: bool = True, + ) -> None: + if not kid or not fingerprint or not secret_ref: + raise ValueError("Signing keys require kid, fingerprint, and secret_ref metadata.") + now = time.time() + with self._transaction("signing-key registration", immediate=True) as conn: + existing = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if existing is not None and existing["status"] == "revoked": + raise OAuthStoreError("A revoked signing key cannot be registered again.") + if active: + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='retired', retired_at=COALESCE(retired_at,?) + WHERE status='active' AND kid<>? + """, + (now, kid), + ) + status = "active" if active else "retired" + conn.execute( + """ + INSERT INTO oauth_signing_keys( + kid, algorithm, fingerprint, status, created_at, + activated_at, retired_at, secret_ref + ) VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(kid) DO UPDATE SET + algorithm=excluded.algorithm, + fingerprint=excluded.fingerprint, + status=excluded.status, + activated_at=excluded.activated_at, + retired_at=excluded.retired_at, + secret_ref=excluded.secret_ref + """, + ( + kid, + algorithm, + fingerprint, + status, + now, + now if active else None, + None if active else now, + secret_ref, + ), + ) + self._audit( + conn, + "signing_key_registered", + key_id=kid, + actor_kind="admin", + details={"status": status}, + ) + + def activate_signing_key(self, kid: str) -> bool: + with self._transaction("signing-key activation", immediate=True) as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if row is None or row["status"] == "revoked": + return False + if row["status"] == "active": + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='retired', retired_at=COALESCE(retired_at,?) + WHERE status='active' AND kid<>? + """, + (now, kid), + ) + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='active', activated_at=?, retired_at=NULL + WHERE kid=? + """, + (now, kid), + ) + self._audit( + conn, + "signing_key_activated", + key_id=kid, + actor_kind="admin", + details={}, + ) + return True + + def retire_signing_key(self, kid: str) -> bool: + with self._transaction("signing-key retirement", immediate=True) as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if row is None or row["status"] == "revoked": + return False + if row["status"] == "retired": + return True + now = time.time() + conn.execute( + "UPDATE oauth_signing_keys SET status='retired', retired_at=? WHERE kid=?", + (now, kid), + ) + self._audit( + conn, + "signing_key_retired", + key_id=kid, + actor_kind="admin", + details={}, + ) + return True + + def revoke_signing_key(self, kid: str) -> bool: + with self._transaction("signing-key revocation", immediate=True) as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + if row is None: + return False + if row["status"] == "revoked": + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_signing_keys + SET status='revoked', revoked_at=? + WHERE kid=? + """, + (now, kid), + ) + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=COALESCE(revoked_at,?), + revoke_reason=COALESCE(revoke_reason,'signing_key_revoked') + WHERE signing_kid=? + """, + (now, kid), + ) + self._audit( + conn, + "signing_key_revoked", + key_id=kid, + actor_kind="admin", + details={"severity": "high"}, + ) + return True + + def signing_key_is_usable(self, kid: str) -> bool: + with self._connection("signing-key query") as conn: + row = conn.execute( + "SELECT status FROM oauth_signing_keys WHERE kid=?", + (kid,), + ).fetchone() + return row is not None and row["status"] in {"active", "retired"} + + def record_access_token( + self, + jti: str, + grant_id: str, + client_id: str, + signing_kid: str, + scopes: str, + *, + issued_at: float, + expires_at: float, + token_mode: str = "standard", + ) -> None: + if not jti: + raise ValueError("Access-token jti must not be empty.") + with self._transaction("access-token recording", immediate=True) as conn: + self._record_access_token_in_transaction( + conn, + jti, + grant_id, + client_id, + signing_kid, + scopes, + issued_at=issued_at, + expires_at=expires_at, + token_mode=token_mode, + ) + + def _record_access_token_in_transaction( + self, + conn: sqlite3.Connection, + jti: str, + grant_id: str, + client_id: str, + signing_kid: str, + scopes: str, + *, + issued_at: float, + expires_at: float, + token_mode: str, + ) -> None: + conn.execute( + """ + INSERT INTO oauth_access_tokens( + jti, grant_id, client_id, signing_kid, scopes, + token_mode, issued_at, expires_at + ) VALUES(?,?,?,?,?,?,?,?) + """, + ( + jti, + grant_id, + client_id, + signing_kid, + scopes, + token_mode, + issued_at, + expires_at, + ), + ) + self._audit( + conn, + "access_token_issued", + client_id=client_id, + grant_id=grant_id, + token_id=jti, + key_id=signing_kid, + actor_kind="server", + details={"mode": token_mode}, + ) + + def active_access_token_identity( + self, + jti: str, + *, + now: float | None = None, + ) -> dict[str, str] | None: + checked_at = time.time() if now is None else now + with self._transaction("access-token query") as conn: + row = conn.execute( + """ + SELECT t.expires_at, t.revoked_at, t.client_id, t.grant_id, + c.enabled AS client_enabled, c.revoked_at AS client_revoked, + g.enabled AS grant_enabled, g.revoked_at AS grant_revoked, + g.workspace_id AS workspace_id, k.status AS key_status + FROM oauth_access_tokens t + JOIN oauth_clients c ON c.client_id=t.client_id + JOIN oauth_grants g ON g.grant_id=t.grant_id + JOIN oauth_signing_keys k ON k.kid=t.signing_kid + WHERE t.jti=? + """, + (jti,), + ).fetchone() + if row is None: + return None + workspace_id = row["workspace_id"] + active = ( + row["expires_at"] > checked_at + and row["revoked_at"] is None + and bool(row["client_enabled"]) + and row["client_revoked"] is None + and bool(row["grant_enabled"]) + and row["grant_revoked"] is None + and row["key_status"] in {"active", "retired"} + and isinstance(workspace_id, str) + and bool(workspace_id) + ) + if not active: + return None + conn.execute( + """ + UPDATE oauth_access_tokens + SET last_used_at=? + WHERE jti=? AND (last_used_at IS NULL OR last_used_at < ?) + """, + (checked_at, jti, checked_at - 60), + ) + return { + "client_id": str(row["client_id"]), + "grant_id": str(row["grant_id"]), + "workspace_id": workspace_id, + "jti": jti, + } + + def access_token_is_active(self, jti: str, *, now: float | None = None) -> bool: + return self.active_access_token_identity(jti, now=now) is not None + + def revoke_access_token(self, jti: str, *, reason: str = "administrator") -> bool: + with self._transaction("access-token revocation", immediate=True) as conn: + row = conn.execute( + """ + SELECT client_id, grant_id, revoked_at + FROM oauth_access_tokens WHERE jti=? + """, + (jti,), + ).fetchone() + if row is None: + return False + if row["revoked_at"] is not None: + return True + conn.execute( + """ + UPDATE oauth_access_tokens + SET revoked_at=?, revoke_reason=? WHERE jti=? + """, + (time.time(), reason, jti), + ) + self._audit( + conn, + "access_token_revoked", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=jti, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def issue_refresh_token( + self, + grant_id: str, + client_id: str, + scopes: str, + *, + expires_at: float, + ) -> tuple[str, str]: + family_id = str(uuid.uuid4()) + token_id = str(uuid.uuid4()) + token = secrets.token_urlsafe(48) + now = time.time() + if expires_at <= now: + raise ValueError("Refresh-token expiry must be in the future.") + with self._transaction("refresh-token issuance", immediate=True) as conn: + state = conn.execute( + """ + SELECT g.enabled AS grant_enabled, g.revoked_at AS grant_revoked, + c.enabled AS client_enabled, c.revoked_at AS client_revoked + FROM oauth_grants g + JOIN oauth_clients c ON c.client_id=g.client_id + WHERE g.grant_id=? AND g.client_id=? + """, + (grant_id, client_id), + ).fetchone() + if ( + state is None + or not bool(state["grant_enabled"]) + or state["grant_revoked"] is not None + or not bool(state["client_enabled"]) + or state["client_revoked"] is not None + ): + raise OAuthStoreError("OAuth client or grant is not active.") + conn.execute( + """ + INSERT INTO oauth_refresh_token_families( + family_id, grant_id, client_id, scopes, created_at, expires_at + ) VALUES(?,?,?,?,?,?) + """, + (family_id, grant_id, client_id, scopes, now, expires_at), + ) + conn.execute( + """ + INSERT INTO oauth_refresh_tokens( + token_id, family_id, token_hash, issued_at, expires_at + ) VALUES(?,?,?,?,?) + """, + (token_id, family_id, self._refresh_hash(token), now, expires_at), + ) + self._audit( + conn, + "refresh_token_issued", + client_id=client_id, + grant_id=grant_id, + token_id=family_id, + actor_kind="server", + details={}, + ) + return family_id, token + + def refresh_token_binding(self, token: str) -> RefreshTokenBinding | None: + digest = self._refresh_hash(token) + with self._connection("refresh-token binding") as conn: + row = self._refresh_token_row(conn, digest) + if row is None: + return None + return RefreshTokenBinding( + family_id=str(row["family_id"]), + client_id=str(row["client_id"]), + grant_id=str(row["grant_id"]), + scopes=str(row["scopes"]), + ) + + def rotate_refresh_token( + self, + token: str, + *, + expires_at: float, + ) -> RefreshTokenResult | None: + now = time.time() + digest = self._refresh_hash(token) + with self._transaction("refresh-token rotation", immediate=True) as conn: + row = self._refresh_token_row(conn, digest) + return self._rotate_refresh_token_in_transaction( + conn, + row, + expires_at=expires_at, + now=now, + ) + + def rotate_refresh_token_and_record_access_token( + self, + token: str, + *, + expected_client_id: str, + refresh_expires_at: float, + access_jti: str, + access_signing_kid: str, + access_scopes: str, + access_issued_at: float, + access_expires_at: float, + token_mode: str = "standard", + ) -> RefreshTokenResult | None: + if not expected_client_id: + raise ValueError("Expected OAuth client id must not be empty.") + if not access_jti: + raise ValueError("Access-token jti must not be empty.") + now = time.time() + digest = self._refresh_hash(token) + with self._transaction("refresh-token exchange", immediate=True) as conn: + row = self._refresh_token_row(conn, digest) + if row is None: + return None + stored_client_id = str(row["client_id"]) + if not secrets.compare_digest(stored_client_id, expected_client_id): + raise RefreshTokenClientMismatchError( + "Refresh token is bound to a different OAuth client." + ) + stored_scopes = str(row["scopes"]) + if stored_scopes != access_scopes: + raise OAuthStoreError( + "Access-token scopes do not match the refresh-token family." + ) + rotated = self._rotate_refresh_token_in_transaction( + conn, + row, + expires_at=refresh_expires_at, + now=now, + ) + if rotated is None: + return None + self._record_access_token_in_transaction( + conn, + access_jti, + rotated.grant_id, + rotated.client_id, + access_signing_kid, + rotated.scopes, + issued_at=access_issued_at, + expires_at=access_expires_at, + token_mode=token_mode, + ) + return rotated + + @staticmethod + def _refresh_token_row( + conn: sqlite3.Connection, + digest: str, + ) -> sqlite3.Row | None: + return conn.execute( + """ + SELECT t.token_id, t.family_id, t.used_at, + t.revoked_at AS token_revoked, t.replacement_token_id, + t.expires_at AS token_expires_at, + f.grant_id, f.client_id, f.scopes, + f.expires_at AS family_expires_at, + f.revoked_at AS family_revoked, + g.enabled AS grant_enabled, g.revoked_at AS grant_revoked, + c.enabled AS client_enabled, c.revoked_at AS client_revoked + FROM oauth_refresh_tokens t + JOIN oauth_refresh_token_families f ON f.family_id=t.family_id + JOIN oauth_grants g ON g.grant_id=f.grant_id + JOIN oauth_clients c ON c.client_id=f.client_id + WHERE t.token_hash=? + """, + (digest,), + ).fetchone() + + def _rotate_refresh_token_in_transaction( + self, + conn: sqlite3.Connection, + row: sqlite3.Row | None, + *, + expires_at: float, + now: float, + ) -> RefreshTokenResult | None: + if row is None: + return None + already_used = ( + row["used_at"] is not None + or row["token_revoked"] is not None + or row["replacement_token_id"] is not None + ) + if already_used: + if row["replacement_token_id"] is not None and row["family_revoked"] is None: + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=?, revoke_reason='refresh_token_reuse' + WHERE family_id=? + """, + (now, row["family_id"]), + ) + conn.execute( + """ + UPDATE oauth_refresh_tokens + SET revoked_at=COALESCE(revoked_at,?), + reuse_detected_at=CASE WHEN token_id=? THEN ? ELSE reuse_detected_at END + WHERE family_id=? + """, + (now, row["token_id"], now, row["family_id"]), + ) + self._audit( + conn, + "refresh_token_reuse", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=row["family_id"], + actor_kind="server", + details={"severity": "high"}, + ) + return None + valid = ( + row["token_expires_at"] > now + and row["family_expires_at"] > now + and row["family_revoked"] is None + and bool(row["grant_enabled"]) + and row["grant_revoked"] is None + and bool(row["client_enabled"]) + and row["client_revoked"] is None + ) + if not valid: + return None + new_token = secrets.token_urlsafe(48) + new_id = str(uuid.uuid4()) + new_expiry = min(expires_at, float(row["family_expires_at"])) + if new_expiry <= now: + return None + conn.execute( + """ + INSERT INTO oauth_refresh_tokens( + token_id, family_id, token_hash, issued_at, expires_at + ) VALUES(?,?,?,?,?) + """, + ( + new_id, + row["family_id"], + self._refresh_hash(new_token), + now, + new_expiry, + ), + ) + conn.execute( + """ + UPDATE oauth_refresh_tokens + SET used_at=?, revoked_at=?, replacement_token_id=? + WHERE token_id=? + """, + (now, now, new_id, row["token_id"]), + ) + conn.execute( + "UPDATE oauth_refresh_token_families SET last_used_at=? WHERE family_id=?", + (now, row["family_id"]), + ) + self._audit( + conn, + "refresh_token_rotated", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=row["family_id"], + actor_kind="server", + details={}, + ) + return RefreshTokenResult( + str(row["family_id"]), + new_token, + str(row["client_id"]), + str(row["grant_id"]), + str(row["scopes"]), + ) + + def refresh_family_is_revoked(self, family_id: str) -> bool: + with self._connection("refresh-family query") as conn: + row = conn.execute( + "SELECT revoked_at FROM oauth_refresh_token_families WHERE family_id=?", + (family_id,), + ).fetchone() + return row is not None and row["revoked_at"] is not None + + def revoke_refresh_family( + self, + family_id: str, + *, + reason: str = "administrator", + ) -> bool: + with self._transaction("refresh-family revocation", immediate=True) as conn: + row = conn.execute( + """ + SELECT client_id, grant_id, revoked_at + FROM oauth_refresh_token_families WHERE family_id=? + """, + (family_id,), + ).fetchone() + if row is None: + return False + if row["revoked_at"] is not None: + return True + now = time.time() + conn.execute( + """ + UPDATE oauth_refresh_token_families + SET revoked_at=?, revoke_reason=? WHERE family_id=? + """, + (now, reason, family_id), + ) + conn.execute( + """ + UPDATE oauth_refresh_tokens + SET revoked_at=COALESCE(revoked_at,?) WHERE family_id=? + """, + (now, family_id), + ) + self._audit( + conn, + "refresh_family_revoked", + client_id=row["client_id"], + grant_id=row["grant_id"], + token_id=family_id, + actor_kind="admin", + details={"reason": reason}, + ) + return True + + def list_clients(self) -> list[dict[str, Any]]: + with self._connection("client listing") as conn: + rows = conn.execute( + "SELECT * FROM oauth_clients ORDER BY created_at DESC, client_id" + ).fetchall() + return [self._client_payload(row) for row in rows] + + @staticmethod + def _client_payload(row: sqlite3.Row) -> dict[str, Any]: + item = dict(row) + raw_redirects = item.pop("redirect_uris_json", None) + item["redirect_uris"] = ( + json.loads(raw_redirects) if raw_redirects else [item["redirect_uri"]] + ) + return item + + def list_grants(self, client_id: str | None = None) -> list[dict[str, Any]]: + query = "SELECT * FROM oauth_grants" + args: tuple[Any, ...] = () + if client_id: + query += " WHERE client_id=?" + args = (client_id,) + query += " ORDER BY created_at DESC, grant_id" + with self._connection("grant listing") as conn: + rows = conn.execute(query, args).fetchall() + return [dict(row) for row in rows] + + def list_access_tokens(self, client_id: str | None = None) -> list[dict[str, Any]]: + query = "SELECT * FROM oauth_access_tokens" + args: tuple[Any, ...] = () + if client_id: + query += " WHERE client_id=?" + args = (client_id,) + query += " ORDER BY issued_at DESC, jti" + with self._connection("access-token listing") as conn: + rows = conn.execute(query, args).fetchall() + return [dict(row) for row in rows] + + def list_refresh_token_families( + self, + client_id: str | None = None, + ) -> list[dict[str, Any]]: + query = "SELECT * FROM oauth_refresh_token_families" + args: tuple[Any, ...] = () + if client_id: + query += " WHERE client_id=?" + args = (client_id,) + query += " ORDER BY created_at DESC, family_id" + with self._connection("refresh-family listing") as conn: + rows = conn.execute(query, args).fetchall() + return [dict(row) for row in rows] + + def list_signing_keys(self) -> list[dict[str, Any]]: + with self._connection("signing-key listing") as conn: + rows = conn.execute( + "SELECT * FROM oauth_signing_keys ORDER BY created_at DESC, kid" + ).fetchall() + return [dict(row) for row in rows] + + def list_audit_events(self, *, limit: int = 100) -> list[dict[str, Any]]: + bounded_limit = max(1, min(int(limit), 500)) + with self._connection("audit listing") as conn: + rows = conn.execute( + """ + SELECT * FROM oauth_audit_events + ORDER BY timestamp DESC, event_id DESC LIMIT ? + """, + (bounded_limit,), + ).fetchall() + result: list[dict[str, Any]] = [] + for row in rows: + item = dict(row) + item["details"] = json.loads(item.pop("details_json")) + result.append(item) + return result + + def _refresh_hash(self, token: str) -> str: + if not isinstance(token, str) or not token: + raise ValueError("Refresh token must be a non-empty string.") + return hmac.new(self.pepper, token.encode("utf-8"), hashlib.sha256).hexdigest() + + @staticmethod + def _audit( + conn: sqlite3.Connection, + event_type: str, + *, + client_id: str | None = None, + grant_id: str | None = None, + token_id: str | None = None, + key_id: str | None = None, + actor_kind: str, + details: dict[str, Any], + ) -> None: + conn.execute( + """ + INSERT INTO oauth_audit_events( + event_id, timestamp, event_type, client_id, grant_id, + token_id, key_id, actor_kind, details_json + ) VALUES(?,?,?,?,?,?,?,?,?) + """, + ( + str(uuid.uuid4()), + time.time(), + event_type, + client_id, + grant_id, + token_id, + key_id, + actor_kind, + json.dumps(details, sort_keys=True, ensure_ascii=True), + ), + ) diff --git a/coding_tools_mcp/processes.py b/coding_tools_mcp/processes.py index 7de337c..82049d8 100644 --- a/coding_tools_mcp/processes.py +++ b/coding_tools_mcp/processes.py @@ -16,6 +16,26 @@ HARD_KILL_SIGNAL = getattr(signal, "SIGKILL", signal.SIGTERM) +def _terminate_windows_process_tree(process: subprocess.Popen[bytes]) -> bool: + try: + completed = subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + if completed.returncode != 0: + return False + try: + process.wait(timeout=2) + except (OSError, subprocess.SubprocessError): + pass + return True + + def terminate_process_group( process: subprocess.Popen[bytes], signum: signal.Signals, @@ -23,15 +43,18 @@ def terminate_process_group( force: bool = False, ) -> None: if not hasattr(os, "killpg"): - if os.name == "nt" and not force: - event = getattr(signal, "CTRL_BREAK_EVENT", None) - if event is not None: - try: - process.send_signal(event) - process.wait(timeout=1) - return - except Exception: - pass + if os.name == "nt": + if not force: + event = getattr(signal, "CTRL_BREAK_EVENT", None) + if event is not None: + try: + process.send_signal(event) + process.wait(timeout=1) + return + except Exception: + pass + if _terminate_windows_process_tree(process): + return try: if force: process.kill() @@ -87,10 +110,11 @@ def spawn_process( details={"platform": os.name, "retry_hint": "Run the command without tty=true."}, ) try: - import pty - - master_fd, slave_fd = pty.openpty() - except (ImportError, OSError) as exc: + openpty = getattr(os, "openpty", None) + if openpty is None: + raise OSError("POSIX pseudo-terminal support is unavailable.") + master_fd, slave_fd = openpty() + except OSError as exc: raise ToolFailure( "TTY_UNSUPPORTED", "A POSIX pseudo-terminal could not be created.", diff --git a/coding_tools_mcp/secret_vault.py b/coding_tools_mcp/secret_vault.py new file mode 100644 index 0000000..0fa43cf --- /dev/null +++ b/coding_tools_mcp/secret_vault.py @@ -0,0 +1,237 @@ +"""Small encrypted secret store used by server-side persistence modules.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +VAULT_VERSION = 1 +RECORD_VERSION = 1 +KDF_NAME = "pbkdf2-sha256" +CIPHER_NAME = "hmac-sha256-stream+hmac-sha256" + + +class SecretVaultError(ValueError): + pass + + +class SecretVault: + def __init__(self, path: str | Path | None, master_key: str | None) -> None: + self.path = Path(path).expanduser() if path else None + self.master_key = master_key or None + + def enabled(self) -> bool: + return self.path is not None and self.master_key is not None + + def status_payload(self) -> dict[str, Any]: + return { + "enabled": self.enabled(), + "path": str(self.path) if self.path else None, + "secret_count": len(self.list_names()), + } + + def list_names(self) -> list[str]: + raw = self._read_raw(require_key=False) + secrets = raw["secrets"] + return sorted(secrets) + + def set_secret(self, name: str, value: str) -> None: + self._require_enabled() + validate_secret_name(name) + if not isinstance(value, str): + raise SecretVaultError("Secret value must be a string.") + assert self.master_key is not None + raw = self._read_raw(require_key=True) + raw["secrets"][name] = encrypt_value(value, self.master_key) + self._write_raw(raw) + + def get_secret(self, name: str) -> str: + self._require_enabled() + validate_secret_name(name) + assert self.master_key is not None + raw = self._read_raw(require_key=True) + record = raw["secrets"].get(name) + if not isinstance(record, dict): + raise SecretVaultError(f"Secret {name!r} is not set.") + return decrypt_value(record, self.master_key) + + def delete_secret(self, name: str) -> bool: + self._require_enabled() + validate_secret_name(name) + raw = self._read_raw(require_key=True) + existed = name in raw["secrets"] + if existed: + raw["secrets"].pop(name) + self._write_raw(raw) + return existed + + def _require_enabled(self) -> None: + if self.path is None: + raise SecretVaultError("Secret vault path is not configured.") + if self.master_key is None: + raise SecretVaultError( + "CODING_TOOLS_MCP_SECRETS_KEY is required to read or write secret values." + ) + + def _read_raw(self, *, require_key: bool) -> dict[str, Any]: + if require_key: + self._require_enabled() + if self.path is None or not self.path.exists(): + return {"version": VAULT_VERSION, "secrets": {}} + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except OSError as exc: + raise SecretVaultError(f"Could not read secret vault: {exc}") from exc + except json.JSONDecodeError as exc: + raise SecretVaultError(f"Secret vault is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise SecretVaultError("Secret vault must be a JSON object.") + if raw.get("version") != VAULT_VERSION: + raise SecretVaultError("Secret vault was written by an unsupported version.") + secrets = raw.get("secrets") + if not isinstance(secrets, dict): + raise SecretVaultError("Secret vault secrets field must be an object.") + for name, record in secrets.items(): + validate_secret_name(name) + if not isinstance(record, dict): + raise SecretVaultError("Secret vault contains an invalid record.") + return {"version": VAULT_VERSION, "secrets": dict(secrets)} + + def _write_raw(self, raw: dict[str, Any]) -> None: + if self.path is None: + raise SecretVaultError("Secret vault path is not configured.") + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", + suffix=".tmp", + dir=self.path.parent, + ) + tmp_path = Path(tmp_name) + payload = {"version": VAULT_VERSION, "secrets": raw.get("secrets", {})} + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + tmp_path.chmod(0o600) + os.replace(tmp_path, self.path) + _fsync_directory(self.path.parent) + except OSError as exc: + raise SecretVaultError(f"Could not atomically save secret vault: {exc}") from exc + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + + +def validate_secret_name(name: str) -> None: + if not isinstance(name, str) or not name or len(name) > 128: + raise SecretVaultError("Secret name must be a non-empty string up to 128 characters.") + allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.-/") + if any(char not in allowed for char in name): + raise SecretVaultError("Secret name contains unsupported characters.") + + +def encrypt_value(value: str, master_key: str) -> dict[str, str | int]: + salt = os.urandom(16) + nonce = os.urandom(16) + enc_key, mac_key = derive_keys(master_key, salt) + plaintext = value.encode("utf-8") + ciphertext = xor_bytes(plaintext, key_stream(enc_key, nonce, len(plaintext))) + tag = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest() + return { + "version": RECORD_VERSION, + "kdf": KDF_NAME, + "cipher": CIPHER_NAME, + "salt": b64e(salt), + "nonce": b64e(nonce), + "ciphertext": b64e(ciphertext), + "tag": b64e(tag), + } + + +def decrypt_value(record: dict[str, Any], master_key: str) -> str: + if ( + record.get("version") != RECORD_VERSION + or record.get("kdf") != KDF_NAME + or record.get("cipher") != CIPHER_NAME + ): + raise SecretVaultError("Secret record uses an unsupported format.") + try: + salt = b64d(str(record["salt"])) + nonce = b64d(str(record["nonce"])) + ciphertext = b64d(str(record["ciphertext"])) + tag = b64d(str(record["tag"])) + except (KeyError, ValueError) as exc: + raise SecretVaultError("Secret record is corrupt.") from exc + if len(salt) != 16 or len(nonce) != 16 or len(tag) != hashlib.sha256().digest_size: + raise SecretVaultError("Secret record is corrupt.") + enc_key, mac_key = derive_keys(master_key, salt) + expected = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + raise SecretVaultError("Secret vault key is incorrect or the record was modified.") + plaintext = xor_bytes(ciphertext, key_stream(enc_key, nonce, len(ciphertext))) + try: + return plaintext.decode("utf-8") + except UnicodeDecodeError as exc: + raise SecretVaultError("Secret record plaintext is not valid UTF-8.") from exc + + +def derive_keys(master_key: str, salt: bytes) -> tuple[bytes, bytes]: + key_material = hashlib.pbkdf2_hmac( + "sha256", + master_key.encode("utf-8"), + salt, + 200_000, + dklen=64, + ) + return key_material[:32], key_material[32:] + + +def key_stream(key: bytes, nonce: bytes, length: int) -> bytes: + output = bytearray() + counter = 0 + while len(output) < length: + output.extend( + hmac.new(key, nonce + counter.to_bytes(8, "big"), hashlib.sha256).digest() + ) + counter += 1 + return bytes(output[:length]) + + +def xor_bytes(left: bytes, right: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(left, right, strict=True)) + + +def b64e(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii") + + +def b64d(value: str) -> bytes: + try: + return base64.b64decode(value.encode("ascii"), altchars=b"-_", validate=True) + except (ValueError, UnicodeEncodeError) as exc: + raise ValueError("invalid base64") from exc + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + try: + descriptor = os.open(path, os.O_RDONLY) + except OSError: + return + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/coding_tools_mcp/server.py b/coding_tools_mcp/server.py index 24a3bb4..9950393 100644 --- a/coding_tools_mcp/server.py +++ b/coding_tools_mcp/server.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import copy import base64 import ctypes import hashlib @@ -32,23 +33,54 @@ from typing import Any, cast from . import __version__ +from .admin import ( + ADMIN_API_PREFIX, + SERVER_SECRET_VAULT_FILENAME, + AdminService, + AdminServiceError, + AdminUnavailableError, + gateway_file_revision, +) from .envutils import ENV_PREFIX, truthy_env +from .codex_sessions import CodexSessionScanner from .errors import JsonRpcError, ToolFailure from .landlock_exec import libc_syscall from .oauth import ( OAUTH_CODE_TTL_SECONDS, OAUTH_GRANT_TYPE_AUTHORIZATION_CODE, + OAUTH_GRANT_TYPE_REFRESH_TOKEN, OAUTH_GRANT_TYPES_SUPPORTED, OAUTH_MAX_BODY_BYTES, OAUTH_RESPONSE_TYPES_SUPPORTED, MAX_PENDING_CODES, OAUTH_TOKEN_TTL_SECONDS, + OAuthClientAuthenticationError, OAuthConfig, + OAuthIdentity, + OAuthInvalidGrantError, + OAuthServiceError, + PersistentOAuthClientRegistry, + authenticate_access_token, create_access_token, + create_authorization_grant, + exchange_refresh_token, + initialize_signing_key_ring, + issue_refresh_token, valid_pkce_challenge, - validate_access_token, verify_pkce, ) +from .oauth_store import OAuthAuthorizationStore, OAuthStoreError +from .secret_vault import SecretVault, SecretVaultError +from .settings_definition import ( + SettingsValidationError, + normalize_allowed_origins, + normalize_oauth_client_workspace_bindings, +) +from .settings_store import ( + ServerSettingsStore, + SettingsStoreError, + default_settings_dir, +) from .patching import ( AtomicPatchCommitter, FileBaseline, @@ -79,8 +111,22 @@ from .telemetry import SessionTelemetry from .textutils import DEFAULT_MAX_LINES, TextTruncation, truncate_text_head from .tool_results import make_tool_result +from .transcript import TranscriptStore from .transport_http import HTTPSessionManager from .transport_stdio import serve_stdio +from .upstream import ( + UpstreamConfigError, + UpstreamConfigSnapshot, + UpstreamManager, + load_upstream_config_snapshot, +) +from .workspace_binding import ( + WorkspaceBinding, + WorkspaceBindingError, + WorkspaceBindingResolver, +) +from .webui import admin_console_html +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError SERVER_NAME = "coding-tools-mcp" @@ -323,6 +369,20 @@ class RuntimePolicy: fake_readonly_annotations: bool = False +@dataclass(frozen=True) +class AuthorizationContext: + method: str + oauth_identity: OAuthIdentity | None = None + + def authorization_key(self, workspace_id: str) -> tuple[str, str | None, str | None, str]: + return ( + self.method, + self.oauth_identity.client_id if self.oauth_identity is not None else None, + self.oauth_identity.grant_id if self.oauth_identity is not None else None, + workspace_id, + ) + + OAUTH_TOKEN_AUTH_METHODS = ("client_secret_basic", "client_secret_post", "none") @@ -700,35 +760,25 @@ def json_response_payload(payload: Any) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") -@functools.lru_cache(maxsize=8) -def _configured_allowed_origins(raw: str) -> frozenset[str]: - return frozenset(item.strip().rstrip("/") for item in raw.split(",") if item.strip()) +_ACTIVE_ALLOWED_ORIGINS: frozenset[str] = frozenset() + + +def configure_allowed_origins(value: Any) -> frozenset[str]: + global _ACTIVE_ALLOWED_ORIGINS + normalized = frozenset(normalize_allowed_origins(value)) + _ACTIVE_ALLOWED_ORIGINS = normalized + return normalized def is_allowed_origin(origin: str) -> bool: - # Authentication does not replace browser Origin validation. + # Authentication does not replace browser Origin validation. The same + # canonical validator is used for startup and Admin settings writes. try: - parsed = urllib.parse.urlparse(origin) - except ValueError: - return False - if ( - parsed.scheme not in {"http", "https"} - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.path not in {"", "/"} - or parsed.params - or parsed.query - or parsed.fragment - ): + normalized_values = normalize_allowed_origins([origin]) + parsed = urllib.parse.urlsplit(normalized_values[0]) + except (IndexError, SettingsValidationError, ValueError): return False - try: - _ = parsed.port - except ValueError: - return False - normalized = origin.rstrip("/") - configured = _configured_allowed_origins(os.environ.get(f"{ENV_PREFIX}_ALLOWED_ORIGINS", "")) - return parsed.hostname in {"localhost", "127.0.0.1", "::1"} or normalized in configured + return parsed.hostname in {"localhost", "127.0.0.1", "::1"} or normalized_values[0] in _ACTIVE_ALLOWED_ORIGINS def is_loopback_bind_host(host: str) -> bool: @@ -1213,17 +1263,28 @@ def __init__( auth_token: str | None = None, oauth_config: OAuthConfig | None = None, project_context: ProjectContext | None = None, + workspace_binding: WorkspaceBinding | None = None, + authorization_context: AuthorizationContext | None = None, + upstream_manager: UpstreamManager | None = None, fake_readonly_annotations: bool = False, transport: str = "stdio", ) -> None: self.workspace = Workspace(workspace) + if workspace_binding is not None and workspace_binding.root != self.workspace.root: + raise ToolFailure( + "INVALID_ARGUMENT", + "Workspace binding root does not match Runtime workspace.", + category="validation", + ) + self.workspace_binding = workspace_binding or WorkspaceBinding( + "default", + self.workspace.root, + transport, + ) + self.authorization_context = authorization_context or AuthorizationContext( + self.workspace_binding.authorization_method + ) self.enable_view_image = enable_view_image - self._exposed_tool_names = [ - name - for name, spec in TOOL_REGISTRY.items() - if spec.gated_by is None or getattr(self, spec.gated_by) - ] - self._exposed_tool_name_set = frozenset(self._exposed_tool_names) if permission_mode not in PERMISSION_MODE_CHOICES: raise ToolFailure( "INVALID_ARGUMENT", @@ -1256,6 +1317,32 @@ def __init__( self.allow_network = allow_network or self.capabilities.network self.auth_token = auth_token or None self.oauth_config = oauth_config + self.upstream_manager = upstream_manager or UpstreamManager.empty( + PROTOCOL_VERSION, + reserved_names=TOOL_REGISTRY, + ) + local_tool_names = [ + name + for name, spec in TOOL_REGISTRY.items() + if spec.gated_by is None or getattr(self, spec.gated_by) + ] + upstream_definitions = self.upstream_manager.tool_definitions() + self._upstream_tool_definitions = { + str(definition["name"]): definition for definition in upstream_definitions + } + upstream_tool_names = self.upstream_manager.tool_names() + collisions = sorted(set(TOOL_REGISTRY) & set(upstream_tool_names)) + if collisions: + self.upstream_manager.close() + raise ToolFailure( + "UPSTREAM_TOOL_COLLISION", + f"Upstream Gateway collided with reserved local tools: {', '.join(collisions)}", + category="configuration", + ) + self._local_tool_name_set = frozenset(local_tool_names) + self._upstream_tool_name_set = frozenset(upstream_tool_names) + self._exposed_tool_names = [*local_tool_names, *upstream_tool_names] + self._exposed_tool_name_set = frozenset(self._exposed_tool_names) self.server_instance_id = secrets.token_urlsafe(12) self._set_runtime_dir(runtime_dir_for_workspace(self.workspace.root, self.server_instance_id)) self.fallback_runtime_dir = fallback_runtime_dir_for_workspace(self.workspace.root, self.server_instance_id) @@ -1283,6 +1370,9 @@ def __init__( self.telemetry = SessionTelemetry(permission_mode=self.permission_mode, transport=transport) self._tool_handlers = {name: getattr(self, name) for name in TOOL_REGISTRY} + def session_authorization_key(self) -> tuple[str, str | None, str | None, str]: + return self.authorization_context.authorization_key(self.workspace_binding.workspace_id) + def _set_runtime_dir(self, runtime_dir: Path) -> None: self.runtime_dir = runtime_dir self.home_dir = self.runtime_dir / "home" @@ -1302,6 +1392,7 @@ def close(self) -> None: if session.process.poll() is None: terminate_process_group(session.process, signal.SIGTERM) session.drain_readers() + self.upstream_manager.close() shutil.rmtree(self.runtime_dir, ignore_errors=True) self.telemetry.finish() @@ -1383,16 +1474,28 @@ def initialize(self, client_info: dict[str, Any] | None = None) -> dict[str, Any } def list_tools(self) -> dict[str, Any]: - return { - "tools": [ - tool_definition(name, fake_readonly=self.fake_readonly_annotations) - for name in self.exposed_tool_names() - ] - } + local_definitions = [ + tool_definition(name, fake_readonly=self.fake_readonly_annotations) + for name in self._exposed_tool_names + if name in self._local_tool_name_set + ] + upstream_definitions = [ + copy.deepcopy(self._upstream_tool_definitions[name]) + for name in self._exposed_tool_names + if name in self._upstream_tool_name_set + ] + return {"tools": [*local_definitions, *upstream_definitions]} def exposed_tool_names(self) -> list[str]: return list(self._exposed_tool_names) + def real_tool_annotations(self, name: str) -> dict[str, Any]: + if name in self._local_tool_name_set: + return tool_annotations(name, fake_readonly=False) + definition = self._upstream_tool_definitions.get(name) + annotations = definition.get("annotations") if isinstance(definition, dict) else None + return copy.deepcopy(annotations) if isinstance(annotations, dict) else {} + def auth_enabled(self) -> bool: return self.auth_token is not None or self.oauth_config is not None @@ -1459,6 +1562,7 @@ def server_info_payload(self) -> dict[str, Any]: }, "tools": tools, "tool_count": len(tools), + "upstream": self.upstream_manager.status_payload(), } def call_tool( @@ -1470,7 +1574,14 @@ def call_tool( ) -> dict[str, Any]: started_at = time.time() args = arguments or {} - handler = self._tool_handlers.get(name) if name in self._exposed_tool_name_set else None + if name in self._upstream_tool_name_set: + result = self.upstream_manager.call_tool(name, args) + structured = result.get("structuredContent") + payload = copy.deepcopy(structured) if isinstance(structured, dict) else {} + payload.setdefault("ok", not bool(result.get("isError"))) + self.emit_tool_trace(name, args, payload, started_at) + return result + handler = self._tool_handlers.get(name) if name in self._local_tool_name_set else None if handler is None: raise JsonRpcError(-32602, f"Unknown tool: {name}", {"reason": "unknown_tool"}) spec = TOOL_REGISTRY[name] @@ -4013,7 +4124,10 @@ def open_landlock_ruleset(workspace: Path, read_roots: list[str], *, write_roots def add_landlock_path(ruleset_fd: int, path: Path, allowed_access: int, *, required: bool = True) -> None: try: - fd = os.open(path, getattr(os, "O_PATH", os.O_RDONLY) | os.O_CLOEXEC) + fd = os.open( + path, + getattr(os, "O_PATH", os.O_RDONLY) | getattr(os, "O_CLOEXEC", 0), + ) except OSError as exc: if required: raise ToolFailure( @@ -4649,7 +4763,7 @@ def server_card_payload(runtime: Runtime, *, oauth_base_url: str | None = None) names = runtime.exposed_tool_names() # Always the real annotations, never the tools/list override: this card is # what an operator fetches to find out what the endpoint actually does. - annotations = {name: tool_annotations(name, fake_readonly=False) for name in names} + annotations = {name: runtime.real_tool_annotations(name) for name in names} read_only = [name for name in names if annotations[name].get("readOnlyHint") is True] mutating = [name for name in names if annotations[name].get("readOnlyHint") is not True] payload = { @@ -4707,22 +4821,196 @@ def send_rpc_error( head_only=head_only, ) + def _admin_service(self) -> AdminService | None: + service = getattr(self.server, "admin_service", None) # type: ignore[attr-defined] + return service if isinstance(service, AdminService) else None + + def _is_admin_authorized(self) -> bool: + configured = getattr(self.server, "admin_token", None) # type: ignore[attr-defined] + if not isinstance(configured, str) or not configured: + return False + explicit = self.headers.get("X-Admin-Token", "").strip() + bearer = self.headers.get("Authorization", "").strip() + candidates = [explicit] + if bearer.startswith("Bearer "): + candidates.append(bearer.removeprefix("Bearer ").strip()) + return any(value and secrets.compare_digest(value, configured) for value in candidates) + + def _read_admin_json(self) -> dict[str, Any] | None: + if self.command in {"GET", "HEAD", "DELETE"}: + return {} + if self.headers.get_content_type().lower() != "application/json": + self.send_json({"error": {"code": "invalid_content_type", "message": "Content-Type must be application/json"}}, status=415) + return None + raw_length = self.headers.get("Content-Length") + if raw_length is None: + self.send_json({"error": {"code": "invalid_request", "message": "Content-Length is required"}}, status=411) + return None + try: + length = int(raw_length) + except ValueError: + self.send_json({"error": {"code": "invalid_request", "message": "Content-Length must be an integer"}}, status=400) + return None + if length < 0 or length > MAX_HTTP_REQUEST_BYTES: + self.send_json({"error": {"code": "invalid_request", "message": "Admin request body size is invalid"}}, status=413) + return None + try: + value = json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + self.send_json({"error": {"code": "invalid_json", "message": "Body must be valid JSON"}}, status=400) + return None + if not isinstance(value, dict): + self.send_json({"error": {"code": "invalid_request", "message": "Body must be a JSON object"}}, status=400) + return None + return value + + def handle_admin_request(self, method: str, *, head_only: bool = False) -> None: + service = self._admin_service() + if service is None: + self.send_json({"error": "Unknown endpoint"}, status=404, head_only=head_only) + return + origin = self.headers.get("Origin") + if origin and not is_allowed_origin(origin): + self.send_json({"error": {"code": "origin_denied", "message": "Origin denied"}}, status=403, head_only=head_only) + return + if not self._is_admin_authorized(): + self.send_json( + {"error": {"code": "admin_auth_required", "message": "Admin authentication is required"}}, + status=401, + extra_headers={"WWW-Authenticate": 'Bearer realm="coding-tools-mcp-admin"'}, + head_only=head_only, + ) + return + body = self._read_admin_json() + if body is None: + return + parsed = urllib.parse.urlsplit(self.path) + query = {key: values[-1] for key, values in urllib.parse.parse_qs(parsed.query).items() if values} + try: + payload = service.dispatch(method, posixpath.normpath(parsed.path), body, query) + except AdminUnavailableError as exc: + self.send_json( + { + "error": { + "code": exc.code, + "message": "An Admin backing service is unavailable.", + } + }, + status=exc.status, + head_only=head_only, + ) + return + except AdminServiceError as exc: + self.send_json( + {"error": {"code": exc.code, "message": str(exc)}}, + status=exc.status, + head_only=head_only, + ) + return + except (OAuthStoreError, SecretVaultError, SettingsStoreError): + self.send_json( + { + "error": { + "code": "admin_unavailable", + "message": "An Admin backing service is unavailable.", + } + }, + status=503, + head_only=head_only, + ) + return + except Exception: # noqa: BLE001 - Admin responses must remain redacted. + self.send_json( + { + "error": { + "code": "admin_internal_error", + "message": "The Admin request could not be completed.", + } + }, + status=500, + head_only=head_only, + ) + return + self.send_json(payload, head_only=head_only) + def do_GET(self) -> None: + normalized = posixpath.normpath(self.path.split("?", 1)[0]) + if normalized == "/admin": + if self._admin_service() is None: + self.send_json({"error": "Unknown endpoint"}, status=404) + return + origin = self.headers.get("Origin") + if origin and not is_allowed_origin(origin): + self.send_json( + {"error": {"code": "origin_denied", "message": "Origin denied"}}, + status=403, + ) + return + if not self._is_admin_authorized(): + self.send_json( + {"error": {"code": "admin_auth_required", "message": "Admin authentication is required"}}, + status=401, + extra_headers={"WWW-Authenticate": 'Bearer realm="coding-tools-mcp-admin"'}, + ) + return + body = admin_console_html().encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_cors_headers() + self.end_headers() + self.wfile.write(body) + return + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("GET") + return self.handle_metadata_request(head_only=False) def do_HEAD(self) -> None: + normalized = posixpath.normpath(self.path.split("?", 1)[0]) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("GET", head_only=True) + return self.handle_metadata_request(head_only=True) + def do_PUT(self) -> None: + normalized = posixpath.normpath(self.path.split("?", 1)[0]) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("PUT") + return + self.send_json({"error": "Unknown endpoint"}, status=404) + def do_DELETE(self) -> None: request_path = self.path.split("?", 1)[0] - if posixpath.normpath(request_path) != MCP_ENDPOINT_PATH: + normalized = posixpath.normpath(request_path) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("DELETE") + return + if normalized != MCP_ENDPOINT_PATH: self.send_json({"error": "Unknown endpoint"}, status=404) return if not self.is_authorized(): self.send_unauthorized() return session_id = self.headers.get("Mcp-Session-Id") - if not session_id or not self.server.sessions.delete(session_id): # type: ignore[attr-defined] + runtime = self.server.sessions.get(session_id) if session_id else None # type: ignore[attr-defined] + if runtime is None: + self.send_rpc_error(-32001, "Unknown MCP session", status=404) + return + authorization_context = getattr(self, "_authorization_context", None) + if ( + not isinstance(authorization_context, AuthorizationContext) + or runtime.session_authorization_key() + != authorization_context.authorization_key(runtime.workspace_binding.workspace_id) + ): + self.send_rpc_error( + -32000, + "Authorization context does not match the initialized MCP session", + status=403, + ) + return + if not self.server.sessions.delete(session_id): # type: ignore[attr-defined] self.send_rpc_error(-32001, "Unknown MCP session", status=404) return self.send_response(200) @@ -4732,7 +5020,9 @@ def do_DELETE(self) -> None: def do_OPTIONS(self) -> None: request_path = self.path.split("?", 1)[0] - if posixpath.normpath(request_path) not in { + normalized = posixpath.normpath(request_path) + if not normalized.startswith(ADMIN_API_PREFIX) and normalized not in { + "/admin", MCP_ENDPOINT_PATH, "/.well-known/mcp.json", "/.well-known/mcp/server-card.json", @@ -4749,7 +5039,7 @@ def do_OPTIONS(self) -> None: self.send_json({"error": "Origin denied"}, status=403) return self.send_response(204) - self.send_header("Allow", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Allow", "GET, HEAD, POST, PUT, DELETE, OPTIONS") self.send_cors_headers() self.end_headers() @@ -4789,6 +5079,9 @@ def handle_metadata_request(self, *, head_only: bool) -> None: def do_POST(self) -> None: request_path = self.path.split("?", 1)[0] normalized = posixpath.normpath(request_path) + if normalized.startswith(ADMIN_API_PREFIX): + self.handle_admin_request("POST") + return if normalized == "/oauth/authorize": self.handle_oauth_authorize_post() return @@ -4868,9 +5161,18 @@ def do_POST(self) -> None: -32600, "initialize must not include Mcp-Session-Id", request_id=request.get("id") ) return + authorization_context = getattr(self, "_authorization_context", None) + if not isinstance(authorization_context, AuthorizationContext): + self.send_rpc_error( + -32000, + "Request authorization context is unavailable", + status=503, + request_id=request.get("id"), + ) + return try: - self._runtime = self.server.sessions.create() # type: ignore[attr-defined] - except RuntimeError as exc: + self._runtime = self.server.sessions.create(authorization_context) # type: ignore[attr-defined] + except (RuntimeError, WorkspaceBindingError) as exc: self.send_rpc_error(-32000, str(exc), status=503, request_id=request.get("id")) return self._send_session_header = True @@ -4882,6 +5184,19 @@ def do_POST(self) -> None: -32001, "Unknown MCP session", status=404, request_id=response_id(request) ) return + authorization_context = getattr(self, "_authorization_context", None) + if ( + not isinstance(authorization_context, AuthorizationContext) + or runtime.session_authorization_key() + != authorization_context.authorization_key(runtime.workspace_binding.workspace_id) + ): + self.send_rpc_error( + -32000, + "Authorization context does not match the initialized MCP session", + status=403, + request_id=request.get("id"), + ) + return self._runtime = runtime self._send_session_header = True if protocol_version != runtime.protocol_version: @@ -4917,15 +5232,31 @@ def handle_rpc(self, request: dict[str, Any]) -> dict[str, Any] | None: return jsonrpc_error(response_id(request), -32603, str(exc)) def is_authorized(self) -> bool: + self._authorization_context = None if not self.runtime.auth_enabled(): + self._authorization_context = AuthorizationContext("noauth") return True header = self.headers.get("Authorization", "").strip() if self.runtime.auth_token is not None: if secrets.compare_digest(header, f"Bearer {self.runtime.auth_token}"): + self._authorization_context = AuthorizationContext("bearer") return True if self.runtime.oauth_config is not None and header.startswith("Bearer "): token = header[len("Bearer "):] - if validate_access_token(token, self.runtime.oauth_config, self.oauth_base_url()): + try: + identity = authenticate_access_token( + token, + self.runtime.oauth_config, + self.oauth_base_url(), + ) + except OAuthStoreError: + self.log_error("OAuth bearer validation unavailable; request denied") + return False + if identity is not None: + self._authorization_context = AuthorizationContext( + "oauth", + oauth_identity=identity, + ) return True return False @@ -5075,10 +5406,16 @@ def handle_oauth_authorize_get(self) -> None: if _p("response_type") != "code": self._send_html("

Error

response_type must be 'code'

", status=400) return - if cfg.registry.get(client_id) is None: + try: + client = cfg.registry.get(client_id) + redirect_allowed = cfg.registry.accepts_redirect(client_id, redirect_uri) + except OAuthStoreError: + self._send_html("

Error

OAuth persistence is unavailable

", status=503) + return + if client is None: self._send_html("

Error

Unknown client_id

", status=400) return - if not cfg.registry.accepts_redirect(client_id, redirect_uri): + if not redirect_allowed: self._send_html("

Error

redirect_uri is not registered for this client

", status=400) return if code_challenge_method != "S256" or not valid_pkce_challenge(code_challenge): @@ -5121,7 +5458,13 @@ def fail(error: str, status: int = 400) -> None: error=error, ), status=status) - if cfg.registry.get(client_id) is None or not cfg.registry.accepts_redirect(client_id, redirect_uri): + try: + client = cfg.registry.get(client_id) + redirect_allowed = cfg.registry.accepts_redirect(client_id, redirect_uri) + except OAuthStoreError: + fail("OAuth persistence is unavailable", status=503) + return + if client is None or not redirect_allowed: fail("Invalid client or redirect URI") return if code_challenge_method != "S256" or not valid_pkce_challenge(code_challenge): @@ -5133,6 +5476,16 @@ def fail(error: str, status: int = 400) -> None: if not secrets.compare_digest(password, cfg.password): fail("Invalid password", status=401) return + try: + grant_id = create_authorization_grant( + cfg, + client_id=client_id, + redirect_uri=redirect_uri, + scopes="mcp", + ) + except OAuthServiceError: + fail("OAuth authorization store is unavailable", status=503) + return code = secrets.token_urlsafe(32) now = time.time() @@ -5147,6 +5500,7 @@ def fail(error: str, status: int = 400) -> None: "client_id": client_id, "redirect_uri": redirect_uri, "state": state, + "grant_id": grant_id, "expires_at": now + OAUTH_CODE_TTL_SECONDS, "server_url": self.oauth_base_url(), "resource": resource.rstrip("/"), @@ -5167,9 +5521,12 @@ def handle_oauth_token(self) -> None: self.send_json({"error": "unsupported_grant_type"}, status=400) return - def _err(error: str, description: str) -> None: + def _err(error: str, description: str, *, status: int = 400) -> None: self.log_message("OAuth token error: %s - %s", error, description) - self.send_json({"error": error, "error_description": description}, status=400) + self.send_json( + {"error": error, "error_description": description}, + status=status, + ) body = self._read_oauth_body() if body is None: @@ -5203,13 +5560,53 @@ def _err(error: str, description: str) -> None: except Exception: # noqa: BLE001 pass + if grant_type == OAUTH_GRANT_TYPE_REFRESH_TOKEN: + refresh_token = _p("refresh_token") + if not refresh_token: + _err("invalid_grant", "refresh_token is required") + return + try: + response = exchange_refresh_token( + cfg, + refresh_token=refresh_token, + client_id=client_id, + client_secret=client_secret, + auth_method=presented_auth_method, + server_url=self.oauth_base_url(), + ) + except OAuthClientAuthenticationError: + _err("invalid_client", "Invalid client authentication") + return + except OAuthInvalidGrantError: + _err("invalid_grant", "Invalid, expired, or reused refresh token") + return + except OAuthServiceError: + _err( + "server_error", + "Refresh-token persistence is unavailable", + status=503, + ) + return + self.send_json(response) + return if grant_type != OAUTH_GRANT_TYPE_AUTHORIZATION_CODE: - _err("unsupported_grant_type", "Only authorization_code is supported") + supported = ", ".join(OAUTH_GRANT_TYPES_SUPPORTED) + _err("unsupported_grant_type", f"Supported grant types: {supported}") + return + try: + client = cfg.registry.get(client_id) + authenticated = cfg.registry.authenticates( + client_id, + client_secret, + presented_auth_method, + ) + except OAuthStoreError: + _err("server_error", "OAuth client registry is unavailable", status=503) return - if cfg.registry.get(client_id) is None: + if client is None: _err("invalid_client", "Unknown client_id") return - if not cfg.registry.authenticates(client_id, client_secret, presented_auth_method): + if not authenticated: _err("invalid_client", "Invalid client_secret") return if not code: @@ -5241,9 +5638,40 @@ def _err(error: str, description: str) -> None: _err("invalid_grant", "PKCE verification failed") return + grant_id = code_data.get("grant_id") + if not isinstance(grant_id, str) or not grant_id: + _err("server_error", "Authorization grant is unavailable") + return server_url = resource - access_token = create_access_token(cfg, server_url, client_id=client_id) - self.send_json({"access_token": access_token, "token_type": "Bearer", "expires_in": cfg.token_ttl}) + try: + access_token = create_access_token( + cfg, + server_url, + client_id=client_id, + grant_id=grant_id, + ) + refresh_token = issue_refresh_token( + cfg, + grant_id=grant_id, + client_id=client_id, + scopes="mcp", + ) + except OAuthServiceError: + _err( + "server_error", + "OAuth token state could not be persisted", + status=503, + ) + return + self.send_json( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": cfg.token_ttl, + "scope": "mcp", + "refresh_token": refresh_token, + } + ) def handle_oauth_register(self) -> None: cfg = self.runtime.oauth_config @@ -5266,6 +5694,15 @@ def handle_oauth_register(self) -> None: return try: registered = cfg.registry.register(metadata) + except OAuthStoreError: + self.send_json( + { + "error": "server_error", + "error_description": "OAuth persistence is unavailable", + }, + status=503, + ) + return except ValueError as exc: self.send_json({"error": "invalid_client_metadata", "error_description": str(exc)}, status=400) return @@ -5276,10 +5713,10 @@ def send_cors_headers(self) -> None: if origin and is_allowed_origin(origin): self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Vary", "Origin") - self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, OPTIONS") self.send_header( "Access-Control-Allow-Headers", - "Accept, Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id", + "Accept, Authorization, X-Admin-Token, Content-Type, MCP-Protocol-Version, Mcp-Session-Id", ) def send_json( @@ -5314,10 +5751,15 @@ def __init__( handler: type[MCPHandler], control_runtime: Runtime, runtime_factory: Any, + *, + admin_service: AdminService | None = None, + admin_token: str | None = None, ) -> None: super().__init__(address, handler) self.control_runtime = control_runtime self.sessions = HTTPSessionManager(runtime_factory) + self.admin_service = admin_service + self.admin_token = admin_token or None def server_close(self) -> None: self.sessions.close() @@ -5333,21 +5775,36 @@ def build_runtime( oauth_config: OAuthConfig | None = None, emit_warning: bool = True, project_context: ProjectContext | None = None, + workspace_binding: WorkspaceBinding | None = None, + authorization_context: AuthorizationContext | None = None, + upstream_manager: UpstreamManager | None = None, transport: str = "stdio", ) -> Runtime: - workspace = Path(args.workspace or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") or os.getcwd()) - runtime = Runtime( - workspace, - enable_view_image=args.enable_view_image, - permission_mode=runtime_policy.permission_mode, - shell_env_policy=runtime_policy.shell_env_policy, - allow_network=runtime_policy.allow_network, - auth_token=auth_token, - oauth_config=oauth_config, - project_context=project_context, - fake_readonly_annotations=runtime_policy.fake_readonly_annotations, - transport=transport, + workspace = ( + workspace_binding.root + if workspace_binding is not None + else Path(args.workspace or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") or os.getcwd()) ) + try: + runtime = Runtime( + workspace, + enable_view_image=args.enable_view_image, + permission_mode=runtime_policy.permission_mode, + shell_env_policy=runtime_policy.shell_env_policy, + allow_network=runtime_policy.allow_network, + auth_token=auth_token, + oauth_config=oauth_config, + project_context=project_context, + workspace_binding=workspace_binding, + authorization_context=authorization_context, + upstream_manager=upstream_manager, + fake_readonly_annotations=runtime_policy.fake_readonly_annotations, + transport=transport, + ) + except BaseException: + if upstream_manager is not None: + upstream_manager.close() + raise if emit_warning and runtime.capabilities.skip_all_permissions: print( "WARNING: permission_mode=dangerous disables MCP safety gates. Use only inside an isolated container or VM.", @@ -5364,6 +5821,352 @@ def build_runtime( AUTH_MODE_CHOICES = ("bearer", "noauth", "oauth") +OAUTH_DB_FILENAME = "oauth.sqlite3" +OAUTH_SECRET_VAULT_FILENAME = "oauth-secrets.json" +OAUTH_PASSWORD_SECRET = "oauth/authorization-password" +OAUTH_TOKEN_SECRET = "oauth/token-secret" +OAUTH_REFRESH_PEPPER_SECRET = "oauth/refresh-pepper" + + +def _vault_secret( + vault: SecretVault, + name: str, + *, + generated_value: Callable[[], str], +) -> tuple[str, bool]: + if name in vault.list_names(): + return vault.get_secret(name), False + value = generated_value() + vault.set_secret(name, value) + return value, True + + +def _hex_secret( + vault: SecretVault, + name: str, + *, + configured_hex: str | None, + byte_length: int, +) -> bytes: + if configured_hex: + try: + value = bytes.fromhex(configured_hex) + except ValueError as exc: + raise ValueError(f"{name} must be hex-encoded bytes.") from exc + if len(value) < byte_length: + raise ValueError(f"{name} must contain at least {byte_length} bytes.") + normalized = value.hex() + if name not in vault.list_names() or vault.get_secret(name) != normalized: + vault.set_secret(name, normalized) + return value + stored, _created = _vault_secret( + vault, + name, + generated_value=lambda: secrets.token_bytes(byte_length).hex(), + ) + try: + value = bytes.fromhex(stored) + except ValueError as exc: + raise ValueError(f"Secret vault entry {name!r} is not valid hex.") from exc + if len(value) < byte_length: + raise ValueError(f"Secret vault entry {name!r} is too short.") + return value + + +def build_persistent_oauth_config( + config_dir: Path, + *, + master_key: str | None, + password: str | None, + server_url: str | None, + token_ttl: int, + token_secret_hex: str | None = None, + refresh_pepper_hex: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uris: tuple[str, ...] = (), + registration_workspace_id: str | None = "default", + client_workspace_id: str | None = None, +) -> tuple[OAuthConfig, bool]: + vault = SecretVault(config_dir / OAUTH_SECRET_VAULT_FILENAME, master_key) + if not vault.enabled(): + raise ValueError( + f"{ENV_PREFIX}_SECRETS_KEY is required when OAuth persistence is enabled." + ) + resolved_password = password + password_created = False + if not resolved_password: + resolved_password, password_created = _vault_secret( + vault, + OAUTH_PASSWORD_SECRET, + generated_value=lambda: secrets.token_urlsafe(32), + ) + token_secret = _hex_secret( + vault, + OAUTH_TOKEN_SECRET, + configured_hex=token_secret_hex, + byte_length=32, + ) + refresh_pepper = _hex_secret( + vault, + OAUTH_REFRESH_PEPPER_SECRET, + configured_hex=refresh_pepper_hex, + byte_length=32, + ) + store = OAuthAuthorizationStore(config_dir / OAUTH_DB_FILENAME, pepper=refresh_pepper) + signing_kid, active_secret, signing_keys = initialize_signing_key_ring( + store, + vault, + token_secret, + legacy_secret_ref=OAUTH_TOKEN_SECRET, + ) + registry = PersistentOAuthClientRegistry( + store, + registration_workspace_id=registration_workspace_id, + ) + if client_id: + registry.add_preregistered( + client_id, + redirect_uris or ("http://127.0.0.1/callback",), + client_secret=client_secret, + workspace_id=client_workspace_id, + ) + return ( + OAuthConfig( + password=resolved_password, + server_url=server_url, + token_secret=active_secret, + token_ttl=token_ttl, + registry=registry, + store=store, + secret_vault=vault, + signing_kid=signing_kid, + signing_keys=signing_keys, + ), + password_created, + ) + + +SERVER_SETTINGS_FILENAME = "server-settings.json" +UPSTREAM_CONFIG_FILENAME = "mcp-servers.json" +TRANSCRIPT_DB_FILENAME = "transcripts.sqlite3" + + +def load_workspace_startup( + args: argparse.Namespace, +) -> tuple[Path, dict[str, Any], WorkspaceCatalog]: + config_dir = default_settings_dir() + settings = ServerSettingsStore(config_dir / SERVER_SETTINGS_FILENAME).read() + fallback_root = Path( + args.workspace + or os.environ.get(f"{ENV_PREFIX}_WORKSPACE") + or os.getcwd() + ) + catalog = WorkspaceCatalog.from_settings(settings, fallback_root) + return config_dir, settings, catalog + + +def upstream_config_path(args: argparse.Namespace, config_dir: Path) -> Path: + explicit = ( + getattr(args, "upstream_config", None) + or os.environ.get(f"{ENV_PREFIX}_UPSTREAM_CONFIG") + or None + ) + return Path(str(explicit)).expanduser() if explicit else config_dir / UPSTREAM_CONFIG_FILENAME + + +def load_upstream_startup( + args: argparse.Namespace, + config_dir: Path, +) -> UpstreamConfigSnapshot: + path = upstream_config_path(args, config_dir) + explicit = bool( + getattr(args, "upstream_config", None) + or os.environ.get(f"{ENV_PREFIX}_UPSTREAM_CONFIG") + ) + if not path.exists() and not explicit: + return UpstreamConfigSnapshot.empty() + return load_upstream_config_snapshot(path) + + +def upstream_secret_resolver( + snapshot: UpstreamConfigSnapshot, + vault: SecretVault, +) -> Callable[[str], str] | None: + refs: set[str] = set() + for config in snapshot.configs: + for value in config.env.values(): + if isinstance(value, dict): + secret_ref = value.get("secret_ref") + if isinstance(secret_ref, str) and secret_ref: + refs.add(secret_ref) + if not refs: + return vault.get_secret if vault.enabled() else None + if not vault.enabled(): + raise SecretVaultError( + "Gateway secret_ref requires CODING_TOOLS_MCP_SECRETS_KEY and the server Secret Vault." + ) + for ref in sorted(refs): + vault.get_secret(ref) + return vault.get_secret + + +def load_upstream_startup_with_revision( + args: argparse.Namespace, + config_dir: Path, +) -> tuple[UpstreamConfigSnapshot, str]: + path = upstream_config_path(args, config_dir) + before = gateway_file_revision(path) + snapshot = load_upstream_startup(args, config_dir) + after = gateway_file_revision(path) + if before != after: + raise UpstreamConfigError( + "Gateway configuration changed while the startup snapshot was being created." + ) + return snapshot, before + + +def build_upstream_manager( + snapshot: UpstreamConfigSnapshot, + *, + secret_resolver: Callable[[str], str] | None = None, +) -> UpstreamManager: + return UpstreamManager.from_snapshot( + snapshot, + protocol_version=PROTOCOL_VERSION, + secret_resolver=secret_resolver, + reserved_names=TOOL_REGISTRY, + ) + + +def apply_oauth_workspace_bindings( + config: OAuthConfig, + catalog: WorkspaceCatalog, + bindings: dict[str, str], +) -> None: + if config.store is None: + raise OAuthServiceError("OAuth authorization store is not configured.") + normalized = normalize_oauth_client_workspace_bindings(bindings, catalog) + for client_id, workspace_id in normalized.items(): + if config.store.get_client(client_id) is None: + raise OAuthServiceError( + f"OAuth Workspace binding references unknown client_id {client_id!r}." + ) + if not config.store.set_client_workspace(client_id, workspace_id): + raise OAuthServiceError( + f"OAuth Workspace binding could not be applied to client_id {client_id!r}." + ) + + enabled = catalog.enabled_entries() + if len(enabled) == 1: + default_id = catalog.default_id + for client in config.store.list_clients(): + if not client.get("workspace_id"): + if not config.store.set_client_workspace(str(client["client_id"]), default_id): + raise OAuthServiceError( + "OAuth client could not be migrated to the sole enabled Workspace." + ) + + +def active_settings_payload( + startup_settings: dict[str, Any], + workspace_catalog: WorkspaceCatalog, + args: argparse.Namespace, + runtime_policy: RuntimePolicy, + allowed_origins: frozenset[str], +) -> dict[str, Any]: + active = dict(startup_settings) + active.update(workspace_catalog.settings_payload()) + active.update( + { + "workspace": str(workspace_catalog.default().root), + "host": str(args.host), + "port": int(args.port), + "permission_mode": runtime_policy.permission_mode, + "shell_env_inherit": runtime_policy.shell_env_policy.inherit, + "allowed_origins": sorted(allowed_origins), + } + ) + return active + + +def resolve_admin_token( + args: argparse.Namespace, + startup_settings: dict[str, Any], + vault: SecretVault, +) -> str | None: + direct = ( + getattr(args, "admin_token", None) + or os.environ.get(f"{ENV_PREFIX}_ADMIN_TOKEN") + or None + ) + if direct: + return str(direct) + secret_ref = startup_settings.get("admin_token_secret_ref") + if not secret_ref: + return None + if not isinstance(secret_ref, str): + raise SecretVaultError("admin_token_secret_ref must be a string.") + return vault.get_secret(secret_ref) + + +class BoundRuntimeFactory: + def __init__( + self, + args: argparse.Namespace, + runtime_policy: RuntimePolicy, + resolver: WorkspaceBindingResolver, + *, + auth_token: str | None, + oauth_config: OAuthConfig | None, + upstream_snapshot: UpstreamConfigSnapshot | None = None, + upstream_secret_resolver: Callable[[str], str] | None = None, + ) -> None: + self.args = args + self.runtime_policy = runtime_policy + self.resolver = resolver + self.auth_token = auth_token + self.oauth_config = oauth_config + self.upstream_snapshot = upstream_snapshot or UpstreamConfigSnapshot.empty() + self.upstream_secret_resolver = upstream_secret_resolver + self._project_contexts: dict[tuple[str, str], ProjectContext] = {} + self._lock = threading.Lock() + + def project_context(self, binding: WorkspaceBinding) -> ProjectContext: + key = (binding.workspace_id, str(binding.root)) + with self._lock: + cached = self._project_contexts.get(key) + if cached is not None: + return cached + loaded = load_project_context(binding.root) + with self._lock: + return self._project_contexts.setdefault(key, loaded) + + def __call__(self, context: AuthorizationContext) -> Runtime: + binding = self.resolver.resolve_http(context.method, context.oauth_identity) + try: + upstream_manager = build_upstream_manager( + self.upstream_snapshot, + secret_resolver=self.upstream_secret_resolver, + ) + except UpstreamConfigError as exc: + raise RuntimeError("Upstream Gateway initialization failed.") from exc + try: + return build_runtime( + self.args, + self.runtime_policy, + auth_token=self.auth_token, + oauth_config=self.oauth_config, + emit_warning=False, + project_context=self.project_context(binding), + workspace_binding=binding, + authorization_context=context, + upstream_manager=upstream_manager, + transport="http", + ) + except BaseException: + upstream_manager.close() + raise def run_http(args: argparse.Namespace) -> int: @@ -5375,9 +6178,37 @@ def run_http(args: argparse.Namespace) -> int: auth_token = args.auth_token or os.environ.get(f"{ENV_PREFIX}_AUTH_TOKEN") or None try: runtime_policy = runtime_policy_from_args(args) - except ValueError as exc: - print(f"ERROR: {exc}", file=sys.stderr) + config_dir, startup_settings, workspace_catalog = load_workspace_startup(args) + workspace_bindings = normalize_oauth_client_workspace_bindings( + startup_settings.get("oauth_client_workspace_bindings"), + workspace_catalog, + ) + upstream_snapshot, active_gateway_revision = load_upstream_startup_with_revision( + args, + config_dir, + ) + allowed_origin_source = startup_settings.get("allowed_origins") + if allowed_origin_source is None: + allowed_origin_source = os.environ.get(f"{ENV_PREFIX}_ALLOWED_ORIGINS", "") + allowed_origins = configure_allowed_origins(allowed_origin_source) + except ( + SettingsStoreError, + UpstreamConfigError, + WorkspaceCatalogError, + ValueError, + ) as exc: + print(f"ERROR: Startup configuration is unavailable: {exc}", file=sys.stderr) return 2 + server_vault = SecretVault( + config_dir / SERVER_SECRET_VAULT_FILENAME, + os.environ.get(f"{ENV_PREFIX}_SECRETS_KEY"), + ) + try: + gateway_secret_resolver = upstream_secret_resolver(upstream_snapshot, server_vault) + except SecretVaultError as exc: + print(f"ERROR: Gateway credentials are unavailable: {exc}", file=sys.stderr) + return 2 + workspace_resolver = WorkspaceBindingResolver(workspace_catalog) oauth_config: OAuthConfig | None = None oauth_mode = ( @@ -5388,55 +6219,76 @@ def run_http(args: argparse.Namespace) -> int: if oauth_mode: client_id = os.environ.get(f"{ENV_PREFIX}_OAUTH_CLIENT_ID") or None client_secret = os.environ.get(f"{ENV_PREFIX}_OAUTH_CLIENT_SECRET") or None - env_password = os.environ.get(f"{ENV_PREFIX}_OAUTH_PASSWORD") - password = env_password or secrets.token_urlsafe(32) + env_password = os.environ.get(f"{ENV_PREFIX}_OAUTH_PASSWORD") or None + client_workspace_id = ( + os.environ.get(f"{ENV_PREFIX}_OAUTH_WORKSPACE_ID") + or (workspace_bindings.get(client_id) if client_id else None) + ) + if client_id and client_workspace_id: + workspace_bindings[client_id] = client_workspace_id + registration_workspace_id = ( + workspace_catalog.default_id + if len(workspace_catalog.enabled_entries()) == 1 + else None + ) server_url = (os.environ.get(f"{ENV_PREFIX}_SERVER_URL") or "").rstrip("/") or None - if not env_password: - print(f"OAuth authorize password: {password}", file=sys.stderr) - raw_secret = os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_SECRET") or "" - if raw_secret: - try: - token_secret = bytes.fromhex(raw_secret) - except ValueError: - print( - f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_SECRET must be hex-encoded bytes.", - file=sys.stderr, - ) - return 2 - if len(token_secret) < 32: - print( - f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_SECRET must contain at least 32 bytes.", - file=sys.stderr, - ) - return 2 - else: - token_secret = secrets.token_bytes(32) try: - token_ttl = int(os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_TTL") or OAUTH_TOKEN_TTL_SECONDS) + token_ttl = int( + os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_TTL") + or OAUTH_TOKEN_TTL_SECONDS + ) except ValueError: print(f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_TTL must be an integer.", file=sys.stderr) return 2 if not 60 <= token_ttl <= 604_800: - print(f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_TTL must be between 60 and 604800 seconds.", file=sys.stderr) + print( + f"ERROR: {ENV_PREFIX}_OAUTH_TOKEN_TTL must be between 60 and 604800 seconds.", + file=sys.stderr, + ) return 2 - oauth_config = OAuthConfig( - password=password, - server_url=server_url, - token_secret=token_secret, - token_ttl=token_ttl, + raw_redirects = ( + os.environ.get(f"{ENV_PREFIX}_OAUTH_REDIRECT_URIS") + or "http://127.0.0.1/callback" ) - if client_id: - raw_redirects = os.environ.get(f"{ENV_PREFIX}_OAUTH_REDIRECT_URIS") or "http://127.0.0.1/callback" - redirect_uris = tuple(item.strip() for item in raw_redirects.split(",") if item.strip()) - try: - oauth_config.registry.add_preregistered( - client_id, - redirect_uris, - client_secret=client_secret, - ) - except ValueError as exc: - print(f"ERROR: invalid OAuth redirect URI configuration: {exc}", file=sys.stderr) - return 2 + redirect_uris = tuple( + item.strip() for item in raw_redirects.split(",") if item.strip() + ) + try: + oauth_config, password_created = build_persistent_oauth_config( + config_dir, + master_key=os.environ.get(f"{ENV_PREFIX}_SECRETS_KEY"), + password=env_password, + server_url=server_url, + token_ttl=token_ttl, + token_secret_hex=( + os.environ.get(f"{ENV_PREFIX}_OAUTH_TOKEN_SECRET") or None + ), + refresh_pepper_hex=( + os.environ.get(f"{ENV_PREFIX}_OAUTH_REFRESH_TOKEN_PEPPER") + or None + ), + client_id=client_id, + client_secret=client_secret, + redirect_uris=redirect_uris, + registration_workspace_id=registration_workspace_id, + client_workspace_id=client_workspace_id, + ) + apply_oauth_workspace_bindings( + oauth_config, + workspace_catalog, + workspace_bindings, + ) + except ( + OSError, + OAuthServiceError, + OAuthStoreError, + SecretVaultError, + ValueError, + ) as exc: + print(f"ERROR: OAuth persistence is unavailable: {exc}", file=sys.stderr) + return 2 + if password_created: + print(f"OAuth authorize password: {oauth_config.password}", file=sys.stderr) if auth_token: print( "Auth: dual credentials enabled — both static bearer token and OAuth 2.1 access tokens will be accepted.", @@ -5473,20 +6325,85 @@ def run_http(args: argparse.Namespace) -> int: ) return 2 - runtime = build_runtime(args, runtime_policy, auth_token=auth_token, oauth_config=oauth_config, transport="http") - - def runtime_factory() -> Runtime: - return build_runtime( - args, - runtime_policy, - auth_token=auth_token, - oauth_config=oauth_config, - emit_warning=False, - project_context=runtime.project_context, - transport="http", + default_workspace = workspace_catalog.default() + control_binding = WorkspaceBinding( + default_workspace.id, + default_workspace.root, + "control", + ) + try: + control_upstream = build_upstream_manager( + upstream_snapshot, + secret_resolver=gateway_secret_resolver, ) + except UpstreamConfigError as exc: + print(f"ERROR: Upstream Gateway configuration is unavailable: {exc}", file=sys.stderr) + return 2 + runtime = build_runtime( + args, + runtime_policy, + auth_token=auth_token, + oauth_config=oauth_config, + project_context=load_project_context(control_binding.root), + workspace_binding=control_binding, + authorization_context=AuthorizationContext("control"), + upstream_manager=control_upstream, + transport="http", + ) + runtime_factory = BoundRuntimeFactory( + args, + runtime_policy, + workspace_resolver, + auth_token=auth_token, + oauth_config=oauth_config, + upstream_snapshot=upstream_snapshot, + upstream_secret_resolver=gateway_secret_resolver, + ) + + try: + admin_token = resolve_admin_token(args, startup_settings, server_vault) + except SecretVaultError as exc: + runtime.close() + print(f"ERROR: Admin authentication is unavailable: {exc}", file=sys.stderr) + return 2 + admin_service: AdminService | None = None + if admin_token: + gateway_path = upstream_config_path(args, config_dir) + try: + admin_active_settings = active_settings_payload( + startup_settings, + workspace_catalog, + args, + runtime_policy, + allowed_origins, + ) + if oauth_config is not None and oauth_config.server_url is not None: + admin_active_settings["oauth_server_url"] = oauth_config.server_url + admin_service = AdminService( + settings_store=ServerSettingsStore(config_dir / SERVER_SETTINGS_FILENAME), + active_settings=admin_active_settings, + fallback_workspace=workspace_catalog.default().root, + gateway_path=gateway_path, + active_gateway_revision=active_gateway_revision, + secret_vault=server_vault, + oauth_store=oauth_config.store if oauth_config is not None else None, + active_gateway_status=runtime.upstream_manager.status_payload, + transcript_store=TranscriptStore(config_dir / TRANSCRIPT_DB_FILENAME), + session_scanner=CodexSessionScanner(), + ) + except (AdminServiceError, OSError, SecretVaultError, SettingsStoreError) as exc: + runtime.close() + print(f"ERROR: Admin service is unavailable: {exc}", file=sys.stderr) + return 2 - server = RuntimeHTTPServer((args.host, args.port), MCPHandler, runtime, runtime_factory) + server = RuntimeHTTPServer( + (args.host, args.port), + MCPHandler, + runtime, + runtime_factory, + admin_service=admin_service, + admin_token=admin_token, + ) if oauth_config: url_label = oauth_config.server_url or "dynamic request URL" suffix = " + bearer" if runtime.auth_token else "" @@ -5509,16 +6426,50 @@ def runtime_factory() -> Runtime: def run_stdio(args: argparse.Namespace) -> int: try: runtime_policy = runtime_policy_from_args(args) - except ValueError as exc: - print(f"ERROR: {exc}", file=sys.stderr) + config_dir, _settings, workspace_catalog = load_workspace_startup(args) + binding = WorkspaceBindingResolver(workspace_catalog).resolve_stdio() + upstream_snapshot = load_upstream_startup(args, config_dir) + server_vault = SecretVault( + config_dir / SERVER_SECRET_VAULT_FILENAME, + os.environ.get(f"{ENV_PREFIX}_SECRETS_KEY"), + ) + gateway_secret_resolver = upstream_secret_resolver(upstream_snapshot, server_vault) + upstream_manager = build_upstream_manager( + upstream_snapshot, + secret_resolver=gateway_secret_resolver, + ) + except ( + SettingsStoreError, + UpstreamConfigError, + WorkspaceBindingError, + WorkspaceCatalogError, + ValueError, + ) as exc: + print(f"ERROR: Startup configuration is unavailable: {exc}", file=sys.stderr) return 2 - runtime = build_runtime(args, runtime_policy) + runtime = build_runtime( + args, + runtime_policy, + project_context=load_project_context(binding.root), + workspace_binding=binding, + authorization_context=AuthorizationContext("stdio"), + upstream_manager=upstream_manager, + transport="stdio", + ) return serve_stdio(runtime) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Serve workspace-confined coding tools over MCP.") parser.add_argument("--workspace", help="workspace root; defaults to CODING_TOOLS_MCP_WORKSPACE or cwd") + parser.add_argument( + "--upstream-config", + default=None, + help=( + "JSON config for upstream MCP Gateway servers; defaults to " + f"{ENV_PREFIX}_UPSTREAM_CONFIG or the stable config directory/{UPSTREAM_CONFIG_FILENAME}" + ), + ) parser.add_argument( "--host", default=os.environ.get(f"{ENV_PREFIX}_HOST") or "127.0.0.1", @@ -5536,6 +6487,14 @@ def build_parser() -> argparse.ArgumentParser: default=None, help=f"require Authorization: Bearer on /mcp; defaults to {ENV_PREFIX}_AUTH_TOKEN", ) + parser.add_argument( + "--admin-token", + default=None, + help=( + "enable the authenticated Admin API with a dedicated token; defaults to " + f"{ENV_PREFIX}_ADMIN_TOKEN" + ), + ) parser.add_argument( "--oauth-mode", action="store_true", diff --git a/coding_tools_mcp/settings_definition.py b/coding_tools_mcp/settings_definition.py new file mode 100644 index 0000000..b7f983b --- /dev/null +++ b/coding_tools_mcp/settings_definition.py @@ -0,0 +1,331 @@ +"""Canonical validation and migration rules for persisted startup settings.""" + +from __future__ import annotations + +import ipaddress +import re +import urllib.parse +from pathlib import Path +from typing import Any + +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError + + +PERMISSION_MODE_CHOICES = ("safe", "trusted", "dangerous") +SHELL_ENV_INHERIT_CHOICES = ("core", "all", "none") +LEGACY_TOOL_PROFILE_WARNING = "legacy_tool_profile_ignored" +SECRET_REFERENCE_FIELDS = frozenset( + { + "auth_token_secret_ref", + "admin_token_secret_ref", + "oauth_authorization_password_secret_ref", + "oauth_active_key_secret_ref", + "oauth_refresh_token_pepper_secret_ref", + } +) +RESTART_FIELDS = frozenset( + { + "host", + "port", + "workspace_catalog", + "default_workspace_id", + "oauth_server_url", + "oauth_compatibility_mode", + "oauth_client_workspace_bindings", + "permission_mode", + "shell_env_inherit", + "allowed_origins", + } +) + + +class SettingsValidationError(ValueError): + def __init__( + self, + field_errors: dict[str, str] | None = None, + form_errors: list[str] | None = None, + ) -> None: + self.field_errors = field_errors or {} + self.form_errors = form_errors or [] + message = ( + next(iter(self.field_errors.values()), None) + or "; ".join(self.form_errors) + or "Invalid settings." + ) + super().__init__(message) + + +def migrate_persisted_settings(settings: dict[str, Any]) -> tuple[dict[str, Any], tuple[str, ...]]: + """Return settings with obsolete fields removed and stable warning codes. + + Legacy ``tool_profile`` values are accepted as migration input only. Known + and unknown values have the same outcome: the value is ignored, the fixed + tool catalog remains authoritative, and the field is omitted on the next + successful settings write. + """ + + migrated = dict(settings) + warnings: list[str] = [] + if "tool_profile" in migrated: + migrated.pop("tool_profile", None) + warnings.append(LEGACY_TOOL_PROFILE_WARNING) + return migrated, tuple(warnings) + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _items(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, str): + return [item for item in re.split(r"[\s,]+", value) if item] + if isinstance(value, (list, tuple, set)): + return list(value) + return [value] + + +def normalize_allowed_origins(value: Any) -> list[str]: + normalized: list[str] = [] + for item in _items(value): + raw = _text(item).rstrip("/") + try: + parsed = urllib.parse.urlsplit(raw) + except ValueError: + parsed = None + if ( + not raw + or raw in {"*", "null"} + or parsed is None + or not parsed.scheme + or not parsed.netloc + or parsed.path + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + or parsed.hostname is None + ): + raise SettingsValidationError( + {"allowed_origins": f"Unsupported allowed origin: {raw or 'empty value'}"} + ) + host = parsed.hostname.lower() + if ":" in host and not host.startswith("["): + host = f"[{host}]" + try: + port = parsed.port + except ValueError as exc: + raise SettingsValidationError( + {"allowed_origins": f"Allowed origin has an invalid port: {raw}"} + ) from exc + origin = urllib.parse.urlunsplit( + (parsed.scheme.lower(), f"{host}:{port}" if port is not None else host, "", "", "") + ) + if origin not in normalized: + normalized.append(origin) + return normalized + + +def _normalize_host(value: Any) -> str: + host = _text(value) + if not host: + raise SettingsValidationError({"host": "Host is required."}) + try: + ipaddress.ip_address(host) + return host + except ValueError: + pass + if len(host) > 253 or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]*", host) is None: + raise SettingsValidationError({"host": "Host must be a valid IP address or hostname."}) + return host.lower() + + +def _normalize_url(value: Any) -> str: + url = _text(value) + if not url: + return "" + try: + parsed = urllib.parse.urlsplit(url) + except ValueError as exc: + raise SettingsValidationError({"oauth_server_url": "OAuth server URL is invalid."}) from exc + if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username or parsed.password: + raise SettingsValidationError( + {"oauth_server_url": "OAuth server URL must use http:// or https:// without user information."} + ) + return urllib.parse.urlunsplit( + (parsed.scheme.lower(), parsed.netloc.lower(), parsed.path.rstrip("/"), "", "") + ) + + +def _normalize_choice(value: Any, field: str, choices: tuple[str, ...]) -> str: + normalized = _text(value).lower() + if normalized not in choices: + raise SettingsValidationError( + {field: f"Unsupported value; expected one of: {', '.join(choices)}."} + ) + return normalized + + +def normalize_oauth_client_workspace_bindings( + value: Any, + catalog: WorkspaceCatalog, +) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise SettingsValidationError( + {"oauth_client_workspace_bindings": "OAuth client Workspace bindings must be an object."} + ) + normalized: dict[str, str] = {} + for raw_client_id, raw_workspace_id in value.items(): + if ( + not isinstance(raw_client_id, str) + or not 1 <= len(raw_client_id) <= 128 + or not all(char.isalnum() or char in "-._~" for char in raw_client_id) + ): + raise SettingsValidationError( + {"oauth_client_workspace_bindings": "OAuth client binding contains an invalid client_id."} + ) + if not isinstance(raw_workspace_id, str) or not raw_workspace_id: + raise SettingsValidationError( + {"oauth_client_workspace_bindings": "OAuth client binding requires a Workspace id."} + ) + try: + workspace = catalog.get(raw_workspace_id) + except WorkspaceCatalogError as exc: + raise SettingsValidationError( + { + "oauth_client_workspace_bindings": ( + f"OAuth client {raw_client_id!r} references an unknown or disabled Workspace." + ) + } + ) from exc + normalized[raw_client_id] = workspace.id + return dict(sorted(normalized.items())) + + +def _canonicalize_catalog(settings: dict[str, Any], fallback_workspace: str | Path) -> None: + try: + catalog = WorkspaceCatalog.from_settings(settings, fallback_workspace) + except WorkspaceCatalogError as exc: + raise SettingsValidationError({"workspace_catalog": str(exc)}) from exc + settings.update(catalog.settings_payload()) + settings["workspace"] = str(catalog.default().root) + + +def normalize_startup_settings_with_warnings( + current: dict[str, Any], + updates: dict[str, Any], + fallback_workspace: str | Path, +) -> tuple[dict[str, Any], tuple[str, ...]]: + settings, current_warnings = migrate_persisted_settings(current) + clean_updates, update_warnings = migrate_persisted_settings(updates) + accepted = { + "host", + "port", + "workspace", + "workspace_catalog", + "default_workspace_id", + "oauth_server_url", + "oauth_compatibility_mode", + "oauth_client_workspace_bindings", + "permission_mode", + "shell_env_inherit", + "allowed_origins", + *SECRET_REFERENCE_FIELDS, + } + for key, value in clean_updates.items(): + if key not in accepted: + continue + if key == "allowed_origins": + if value is None: + settings.pop(key, None) + else: + settings[key] = normalize_allowed_origins(value) + elif key in { + "workspace_catalog", + "oauth_compatibility_mode", + "oauth_client_workspace_bindings", + }: + settings[key] = value + elif value is None or value == "": + settings.pop(key, None) + else: + settings[key] = value + + if "host" in settings: + settings["host"] = _normalize_host(settings["host"]) + if "port" in settings: + try: + port = int(settings["port"]) + except (TypeError, ValueError) as exc: + raise SettingsValidationError({"port": "Port must be an integer from 1 to 65535."}) from exc + if not 1 <= port <= 65535: + raise SettingsValidationError({"port": "Port must be an integer from 1 to 65535."}) + settings["port"] = port + if "oauth_server_url" in settings: + settings["oauth_server_url"] = _normalize_url(settings["oauth_server_url"]) + for key, choices in ( + ("permission_mode", PERMISSION_MODE_CHOICES), + ("shell_env_inherit", SHELL_ENV_INHERIT_CHOICES), + ): + if key in settings: + settings[key] = _normalize_choice(settings[key], key, choices) + if "oauth_compatibility_mode" in settings and not isinstance( + settings["oauth_compatibility_mode"], bool + ): + raise SettingsValidationError( + {"oauth_compatibility_mode": "OAuth compatibility mode must be a boolean."} + ) + if ( + "workspace_catalog" in settings + or "default_workspace_id" in settings + or "workspace" in clean_updates + ): + _canonicalize_catalog(settings, fallback_workspace) + if "oauth_client_workspace_bindings" in settings: + try: + catalog = WorkspaceCatalog.from_settings(settings, fallback_workspace) + except WorkspaceCatalogError as exc: + raise SettingsValidationError({"workspace_catalog": str(exc)}) from exc + settings["oauth_client_workspace_bindings"] = normalize_oauth_client_workspace_bindings( + settings["oauth_client_workspace_bindings"], + catalog, + ) + + warnings = tuple(dict.fromkeys((*current_warnings, *update_warnings))) + return settings, warnings + + +def normalize_startup_settings( + current: dict[str, Any], + updates: dict[str, Any], + fallback_workspace: str | Path, +) -> dict[str, Any]: + settings, _warnings = normalize_startup_settings_with_warnings( + current, + updates, + fallback_workspace, + ) + return settings + + +def pending_restart_fields( + active: dict[str, Any], + persisted: dict[str, Any], +) -> list[str]: + return sorted( + field + for field in RESTART_FIELDS + if active.get(field) != persisted.get(field) + ) + + +def schema_payload() -> dict[str, Any]: + return { + "permission_mode": list(PERMISSION_MODE_CHOICES), + "shell_env_inherit": list(SHELL_ENV_INHERIT_CHOICES), + "restart_fields": sorted(RESTART_FIELDS), + "migration_warnings": [LEGACY_TOOL_PROFILE_WARNING], + } diff --git a/coding_tools_mcp/settings_store.py b/coding_tools_mcp/settings_store.py new file mode 100644 index 0000000..a0302fd --- /dev/null +++ b/coding_tools_mcp/settings_store.py @@ -0,0 +1,144 @@ +"""Versioned server settings kept independently of a managed workspace.""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from .envutils import ENV_PREFIX +from .settings_definition import migrate_persisted_settings + + +class SettingsStoreError(RuntimeError): + pass + + +SECRET_SETTING_KEYS = frozenset( + { + "auth_token", + "admin_token", + "oauth_password", + "oauth_token_secret", + "oauth_refresh_token_pepper", + } +) +SETTINGS_SCHEMA_VERSION = 1 + + +@dataclass(frozen=True) +class SettingsReadResult: + settings: dict[str, Any] + warnings: tuple[str, ...] + migrated: bool + + +def default_settings_dir(app_name: str = "coding-tools-mcp") -> Path: + configured = os.environ.get(f"{ENV_PREFIX}_CONFIG_DIR") + if configured: + return Path(configured).expanduser() + if os.name == "nt": + base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") + else: + base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + return base / app_name + + +def sanitize_settings(settings: dict[str, Any]) -> dict[str, Any]: + result = dict(settings) + for key in SECRET_SETTING_KEYS: + if result.get(key): + result[f"{key}_configured"] = True + result.pop(key, None) + for key in list(result): + if key.endswith("_secret_ref"): + result[key] = {"configured": bool(result[key])} + return result + + +class ServerSettingsStore: + def __init__(self, path: str | Path) -> None: + self.path = Path(path).expanduser() + + def read(self) -> dict[str, Any]: + return self.read_result().settings + + def read_result(self) -> SettingsReadResult: + if not self.path.exists(): + return SettingsReadResult({}, (), False) + try: + raw = json.loads(self.path.read_text(encoding="utf-8")) + except OSError as exc: + raise SettingsStoreError(f"Could not read server settings: {exc}") from exc + except json.JSONDecodeError as exc: + raise SettingsStoreError( + f"Server settings JSON is corrupt; refusing to overwrite {self.path}: {exc}" + ) from exc + if not isinstance(raw, dict): + raise SettingsStoreError("Server settings must be a JSON object.") + + version = raw.get("schema_version", 0) + if isinstance(version, bool) or not isinstance(version, int): + raise SettingsStoreError("Server settings schema_version must be an integer.") + if version not in (0, SETTINGS_SCHEMA_VERSION): + raise SettingsStoreError("Server settings were written by an unsupported schema version.") + + migrated, warnings = migrate_persisted_settings(raw) + changed = migrated != raw or version == 0 + migrated["schema_version"] = SETTINGS_SCHEMA_VERSION + return SettingsReadResult(migrated, warnings, changed) + + def write(self, settings: dict[str, Any]) -> tuple[str, ...]: + if not isinstance(settings, dict): + raise SettingsStoreError("Server settings must be a JSON object.") + payload, warnings = migrate_persisted_settings(settings) + plaintext_keys = sorted( + key for key in SECRET_SETTING_KEYS if payload.get(key) not in (None, "") + ) + if plaintext_keys: + raise SettingsStoreError( + "Secret values must be stored in SecretVault and referenced from settings: " + + ", ".join(plaintext_keys) + ) + payload["schema_version"] = SETTINGS_SCHEMA_VERSION + self.path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{self.path.name}.", + suffix=".tmp", + dir=self.path.parent, + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + if os.name != "nt": + tmp_path.chmod(0o600) + os.replace(tmp_path, self.path) + _fsync_directory(self.path.parent) + except OSError as exc: + raise SettingsStoreError(f"Could not atomically save server settings: {exc}") from exc + finally: + try: + tmp_path.unlink(missing_ok=True) + except OSError: + pass + return warnings + + +def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return + try: + descriptor = os.open(path, os.O_RDONLY) + except OSError: + return + try: + os.fsync(descriptor) + finally: + os.close(descriptor) diff --git a/coding_tools_mcp/transcript.py b/coding_tools_mcp/transcript.py new file mode 100644 index 0000000..1f33daa --- /dev/null +++ b/coding_tools_mcp/transcript.py @@ -0,0 +1,673 @@ +"""Workspace-partitioned chat, context, and imported-session persistence.""" + +from __future__ import annotations + +import json +import sqlite3 +import threading +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterator + + +class TranscriptStoreError(RuntimeError): + pass + + +MAX_PAGE_SIZE = 200 +MAX_CONTENT_CHARS = 2_000_000 +SCHEMA_VERSION = 1 + + +def _now() -> float: + return time.time() + + +def _require_id(value: Any, field: str) -> str: + if not isinstance(value, str) or not value or len(value) > 256: + raise TranscriptStoreError(f"{field} must be a non-empty string up to 256 characters.") + if value in {".", ".."} or any(char in value for char in "\x00/\\"): + raise TranscriptStoreError(f"{field} contains unsupported characters.") + return value + + +def _text(value: Any, *, limit: int = MAX_CONTENT_CHARS) -> str: + if value is None: + return "" + result = str(value).replace("\x00", "\ufffd") + result = result.encode("utf-8", errors="replace").decode("utf-8", errors="replace") + if len(result) > limit: + raise TranscriptStoreError(f"Text exceeds the {limit}-character limit.") + return result + + +def _json(value: Any) -> str: + try: + return json.dumps(value if value is not None else {}, ensure_ascii=False, sort_keys=True) + except TypeError as exc: + raise TranscriptStoreError(f"Metadata must be JSON serializable: {exc}") from exc + + +def _page(page: int, page_size: int) -> tuple[int, int]: + try: + normalized_page = max(1, int(page)) + normalized_size = max(1, min(int(page_size), MAX_PAGE_SIZE)) + except (TypeError, ValueError) as exc: + raise TranscriptStoreError("page and page_size must be integers.") from exc + return normalized_page, normalized_size + + +def _summary(text: str, limit: int = 240) -> str: + flattened = " ".join(text.split()) + return flattened if len(flattened) <= limit else flattened[: limit - 1] + "\u2026" + + +@dataclass(frozen=True) +class WorkspaceScope: + workspace_id: str + workspace_root: Path + + @classmethod + def create(cls, workspace_id: str, workspace_root: str | Path) -> "WorkspaceScope": + identifier = _require_id(workspace_id, "workspace_id") + try: + root = Path(workspace_root).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise TranscriptStoreError(f"Workspace root cannot be resolved: {exc}") from exc + if not root.is_dir(): + raise TranscriptStoreError("Workspace root must be an existing directory.") + return cls(identifier, root) + + +class TranscriptStore: + """SQLite store where every key is partitioned by explicit Workspace identity.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path).expanduser() + self.path.parent.mkdir(parents=True, exist_ok=True) + self._write_lock = threading.RLock() + self._migrate() + + @contextmanager + def _connection(self, *, write: bool = False) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(self.path, timeout=5.0) + conn.row_factory = sqlite3.Row + try: + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=5000") + if write: + conn.execute("BEGIN IMMEDIATE") + yield conn + if write: + conn.commit() + except BaseException: + if write: + conn.rollback() + raise + finally: + conn.close() + + def _migrate(self) -> None: + with self._write_lock, self._connection(write=True) as conn: + version = int(conn.execute("PRAGMA user_version").fetchone()[0]) + if version > SCHEMA_VERSION: + raise TranscriptStoreError("Transcript database was written by a newer version.") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_conversations( + workspace_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + title TEXT, + source TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, conversation_id) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_messages( + workspace_id TEXT NOT NULL, + message_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + timestamp TEXT, + content TEXT NOT NULL, + source TEXT, + metadata_json TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, message_id), + FOREIGN KEY(workspace_id, conversation_id) + REFERENCES chat_conversations(workspace_id, conversation_id) + ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_context_entries( + workspace_id TEXT NOT NULL, + context_id TEXT NOT NULL, + conversation_id TEXT NOT NULL, + kind TEXT NOT NULL, + timestamp TEXT, + content TEXT NOT NULL, + source TEXT, + metadata_json TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, context_id), + FOREIGN KEY(workspace_id, conversation_id) + REFERENCES chat_conversations(workspace_id, conversation_id) + ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS imported_sessions( + workspace_id TEXT NOT NULL, + session_id TEXT NOT NULL, + conversation_id TEXT, + relative_path TEXT NOT NULL, + source_kind TEXT NOT NULL, + title TEXT, + summary TEXT, + message_count INTEGER NOT NULL DEFAULT 0, + parse_error_count INTEGER NOT NULL DEFAULT 0, + parse_errors_json TEXT NOT NULL DEFAULT '[]', + file_size INTEGER NOT NULL DEFAULT 0, + file_mtime_ns INTEGER NOT NULL DEFAULT 0, + imported_at REAL, + updated_at REAL NOT NULL, + PRIMARY KEY(workspace_id, session_id) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_conversations_updated ON chat_conversations(workspace_id, updated_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_messages_conversation ON chat_messages(workspace_id, conversation_id, created_at, message_id)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_context_conversation ON chat_context_entries(workspace_id, conversation_id, created_at, context_id)" + ) + conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}") + + def scoped(self, workspace_id: str, workspace_root: str | Path) -> "WorkspaceTranscriptService": + return WorkspaceTranscriptService(self, WorkspaceScope.create(workspace_id, workspace_root)) + + def record_messages( + self, + workspace_id: str, + conversation_id: str, + messages: list[dict[str, Any]], + *, + title: str | None = None, + source: str | None = None, + ) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + if not isinstance(messages, list) or len(messages) > 10_000: + raise TranscriptStoreError("messages must be a list with at most 10000 items.") + inserted = 0 + duplicates = 0 + now = _now() + with self._write_lock, self._connection(write=True) as conn: + conn.execute( + """ + INSERT INTO chat_conversations(workspace_id, conversation_id, title, source, created_at, updated_at) + VALUES(?,?,?,?,?,?) + ON CONFLICT(workspace_id, conversation_id) DO UPDATE SET + title=COALESCE(excluded.title, chat_conversations.title), + source=COALESCE(excluded.source, chat_conversations.source), + updated_at=excluded.updated_at + """, + (workspace_id, conversation_id, _text(title, limit=500) or None, _text(source, limit=200) or None, now, now), + ) + for index, message in enumerate(messages): + if not isinstance(message, dict): + raise TranscriptStoreError("Each message must be an object.") + message_id = _require_id( + message.get("message_id") or message.get("id") or f"msg-{uuid.uuid4().hex}", + "message_id", + ) + role = _text(message.get("role") or "unknown", limit=64).lower() + if role not in {"user", "assistant", "system", "tool", "developer", "unknown"}: + role = "unknown" + content = _text(message.get("content")) + timestamp = _text(message.get("timestamp"), limit=128) or None + item_source = _text(message.get("source") or source, limit=200) or None + metadata = message.get("metadata", message.get("metadata_json", {})) + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except json.JSONDecodeError: + metadata = {"raw": _text(metadata, limit=20_000)} + cursor = conn.execute( + """ + INSERT OR IGNORE INTO chat_messages( + workspace_id, message_id, conversation_id, role, timestamp, + content, source, metadata_json, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + (workspace_id, message_id, conversation_id, role, timestamp, content, item_source, _json(metadata), now + index / 1_000_000, now), + ) + if cursor.rowcount: + inserted += 1 + else: + duplicates += 1 + conn.execute( + "UPDATE chat_conversations SET updated_at=? WHERE workspace_id=? AND conversation_id=?", + (now, workspace_id, conversation_id), + ) + return { + "workspace_id": workspace_id, + "conversation_id": conversation_id, + "inserted_count": inserted, + "duplicate_count": duplicates, + } + + def record_context( + self, + workspace_id: str, + conversation_id: str, + entries: list[dict[str, Any]], + *, + title: str | None = None, + source: str | None = None, + ) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + if not isinstance(entries, list) or len(entries) > 10_000: + raise TranscriptStoreError("entries must be a list with at most 10000 items.") + inserted = 0 + duplicates = 0 + now = _now() + with self._write_lock, self._connection(write=True) as conn: + conn.execute( + """ + INSERT INTO chat_conversations(workspace_id, conversation_id, title, source, created_at, updated_at) + VALUES(?,?,?,?,?,?) + ON CONFLICT(workspace_id, conversation_id) DO UPDATE SET + title=COALESCE(excluded.title, chat_conversations.title), + source=COALESCE(excluded.source, chat_conversations.source), + updated_at=excluded.updated_at + """, + (workspace_id, conversation_id, _text(title, limit=500) or None, _text(source, limit=200) or None, now, now), + ) + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + raise TranscriptStoreError("Each context entry must be an object.") + context_id = _require_id( + entry.get("context_id") or entry.get("entry_id") or entry.get("id") or f"ctx-{uuid.uuid4().hex}", + "context_id", + ) + kind = _text(entry.get("kind") or "note", limit=64).lower() + content = _text(entry.get("content")) + timestamp = _text(entry.get("timestamp"), limit=128) or None + item_source = _text(entry.get("source") or source, limit=200) or None + metadata = entry.get("metadata", entry.get("metadata_json", {})) + if isinstance(metadata, str): + try: + metadata = json.loads(metadata) + except json.JSONDecodeError: + metadata = {"raw": _text(metadata, limit=20_000)} + cursor = conn.execute( + """ + INSERT OR IGNORE INTO chat_context_entries( + workspace_id, context_id, conversation_id, kind, timestamp, + content, source, metadata_json, created_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?) + """, + (workspace_id, context_id, conversation_id, kind, timestamp, content, item_source, _json(metadata), now + index / 1_000_000, now), + ) + if cursor.rowcount: + inserted += 1 + else: + duplicates += 1 + conn.execute( + "UPDATE chat_conversations SET updated_at=? WHERE workspace_id=? AND conversation_id=?", + (now, workspace_id, conversation_id), + ) + return { + "workspace_id": workspace_id, + "conversation_id": conversation_id, + "inserted_count": inserted, + "duplicate_count": duplicates, + } + + def list_conversations( + self, + workspace_id: str | None, + *, + page: int = 1, + page_size: int = 50, + query: str | None = None, + ) -> dict[str, Any]: + if workspace_id is not None: + workspace_id = _require_id(workspace_id, "workspace_id") + page, page_size = _page(page, page_size) + clauses: list[str] = [] + args: list[Any] = [] + if workspace_id is not None: + clauses.append("c.workspace_id=?") + args.append(workspace_id) + if query: + clauses.append("(c.conversation_id LIKE ? OR c.title LIKE ? OR c.source LIKE ?)") + pattern = f"%{_text(query, limit=200)}%" + args.extend((pattern, pattern, pattern)) + where = " WHERE " + " AND ".join(clauses) if clauses else "" + with self._connection() as conn: + total = int(conn.execute(f"SELECT COUNT(*) FROM chat_conversations c{where}", args).fetchone()[0]) + rows = conn.execute( + f""" + SELECT c.workspace_id, c.conversation_id, c.title, c.source, + c.created_at, c.updated_at, + COUNT(DISTINCT m.message_id) AS message_count, + COUNT(DISTINCT x.context_id) AS context_count, + MAX(CASE WHEN m.role='user' THEN substr(m.content,1,240) END) AS preview + FROM chat_conversations c + LEFT JOIN chat_messages m ON m.workspace_id=c.workspace_id AND m.conversation_id=c.conversation_id + LEFT JOIN chat_context_entries x ON x.workspace_id=c.workspace_id AND x.conversation_id=c.conversation_id + {where} + GROUP BY c.workspace_id, c.conversation_id + ORDER BY c.updated_at DESC, c.workspace_id, c.conversation_id + LIMIT ? OFFSET ? + """, + (*args, page_size, (page - 1) * page_size), + ).fetchall() + items = [dict(row) for row in rows] + for item in items: + item["preview"] = _summary(item.get("preview") or "") + return {"items": items, "count": len(items), "total": total, "page": page, "page_size": page_size} + + def conversation_detail( + self, + workspace_id: str, + conversation_id: str, + *, + message_page: int = 1, + message_page_size: int = 100, + context_page: int = 1, + context_page_size: int = 100, + ) -> dict[str, Any] | None: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + message_page, message_page_size = _page(message_page, message_page_size) + context_page, context_page_size = _page(context_page, context_page_size) + with self._connection() as conn: + row = conn.execute( + "SELECT * FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone() + if row is None: + return None + messages_total = int(conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + contexts_total = int(conn.execute( + "SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + messages = conn.execute( + """ + SELECT workspace_id, message_id, conversation_id, role, timestamp, + content, source, metadata_json, created_at, updated_at + FROM chat_messages + WHERE workspace_id=? AND conversation_id=? + ORDER BY created_at, message_id LIMIT ? OFFSET ? + """, + (workspace_id, conversation_id, message_page_size, (message_page - 1) * message_page_size), + ).fetchall() + contexts = conn.execute( + """ + SELECT workspace_id, context_id, conversation_id, kind, timestamp, + content, source, metadata_json, created_at, updated_at + FROM chat_context_entries + WHERE workspace_id=? AND conversation_id=? + ORDER BY created_at, context_id LIMIT ? OFFSET ? + """, + (workspace_id, conversation_id, context_page_size, (context_page - 1) * context_page_size), + ).fetchall() + message_items = [_decode_metadata(dict(item)) for item in messages] + context_items = [_decode_metadata(dict(item)) for item in contexts] + return { + "conversation": dict(row), + "messages": message_items, + "messages_total": messages_total, + "message_page": message_page, + "message_page_size": message_page_size, + "contexts": context_items, + "contexts_total": contexts_total, + "context_page": context_page, + "context_page_size": context_page_size, + } + + def delete_message(self, workspace_id: str, message_id: str) -> dict[str, Any]: + return self._delete_by_id("chat_messages", "message_id", workspace_id, message_id) + + def delete_context(self, workspace_id: str, context_id: str) -> dict[str, Any]: + return self._delete_by_id("chat_context_entries", "context_id", workspace_id, context_id) + + def delete_conversation(self, workspace_id: str, conversation_id: str) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + conversation_id = _require_id(conversation_id, "conversation_id") + with self._write_lock, self._connection(write=True) as conn: + message_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + context_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + cursor = conn.execute( + "DELETE FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ) + return { + "workspace_id": workspace_id, + "conversation_id": conversation_id, + "affected_count": int(cursor.rowcount), + "deleted_message_count": message_count if cursor.rowcount else 0, + "deleted_context_count": context_count if cursor.rowcount else 0, + } + + def clear_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + with self._write_lock, self._connection(write=True) as conn: + conversations = int(conn.execute("SELECT COUNT(*) FROM chat_conversations WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + messages = int(conn.execute("SELECT COUNT(*) FROM chat_messages WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + contexts = int(conn.execute("SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + sessions = int(conn.execute("SELECT COUNT(*) FROM imported_sessions WHERE workspace_id=?", (workspace_id,)).fetchone()[0]) + conn.execute("DELETE FROM chat_conversations WHERE workspace_id=?", (workspace_id,)) + conn.execute("DELETE FROM imported_sessions WHERE workspace_id=?", (workspace_id,)) + return { + "workspace_id": workspace_id, + "affected_count": conversations + messages + contexts + sessions, + "deleted_conversation_count": conversations, + "deleted_message_count": messages, + "deleted_context_count": contexts, + "deleted_session_count": sessions, + } + + def upsert_imported_session(self, workspace_id: str, item: dict[str, Any]) -> None: + workspace_id = _require_id(workspace_id, "workspace_id") + session_id = _require_id(item.get("session_id"), "session_id") + errors = item.get("parse_errors") or [] + with self._write_lock, self._connection(write=True) as conn: + conn.execute( + """ + INSERT INTO imported_sessions( + workspace_id, session_id, conversation_id, relative_path, source_kind, + title, summary, message_count, parse_error_count, parse_errors_json, + file_size, file_mtime_ns, imported_at, updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(workspace_id, session_id) DO UPDATE SET + conversation_id=excluded.conversation_id, + relative_path=excluded.relative_path, + source_kind=excluded.source_kind, + title=excluded.title, + summary=excluded.summary, + message_count=excluded.message_count, + parse_error_count=excluded.parse_error_count, + parse_errors_json=excluded.parse_errors_json, + file_size=excluded.file_size, + file_mtime_ns=excluded.file_mtime_ns, + imported_at=COALESCE(excluded.imported_at, imported_sessions.imported_at), + updated_at=excluded.updated_at + """, + ( + workspace_id, + session_id, + item.get("conversation_id"), + _text(item.get("relative_path"), limit=2000), + _text(item.get("source_kind") or "codex", limit=100), + _text(item.get("title"), limit=500) or None, + _text(item.get("summary"), limit=1000) or None, + int(item.get("message_count") or 0), + len(errors), + _json(errors), + int(item.get("file_size") or 0), + int(item.get("file_mtime_ns") or 0), + item.get("imported_at"), + _now(), + ), + ) + + def list_imported_sessions( + self, + workspace_id: str | None, + *, + page: int = 1, + page_size: int = 50, + ) -> dict[str, Any]: + if workspace_id is not None: + workspace_id = _require_id(workspace_id, "workspace_id") + page, page_size = _page(page, page_size) + where = " WHERE workspace_id=?" if workspace_id else "" + args: tuple[Any, ...] = (workspace_id,) if workspace_id else () + with self._connection() as conn: + total = int(conn.execute(f"SELECT COUNT(*) FROM imported_sessions{where}", args).fetchone()[0]) + rows = conn.execute( + f"SELECT * FROM imported_sessions{where} ORDER BY updated_at DESC, workspace_id, session_id LIMIT ? OFFSET ?", + (*args, page_size, (page - 1) * page_size), + ).fetchall() + items = [] + for row in rows: + item = dict(row) + item["parse_errors"] = json.loads(item.pop("parse_errors_json")) + items.append(item) + return {"items": items, "count": len(items), "total": total, "page": page, "page_size": page_size} + + def delete_imported_session(self, workspace_id: str, session_id: str) -> dict[str, Any]: + workspace_id = _require_id(workspace_id, "workspace_id") + session_id = _require_id(session_id, "session_id") + with self._write_lock, self._connection(write=True) as conn: + session = conn.execute( + "SELECT conversation_id FROM imported_sessions WHERE workspace_id=? AND session_id=?", + (workspace_id, session_id), + ).fetchone() + if session is None: + return { + "workspace_id": workspace_id, + "session_id": session_id, + "affected_count": 0, + "deleted_session_count": 0, + "deleted_conversation_count": 0, + "deleted_message_count": 0, + "deleted_context_count": 0, + } + conversation_id = session["conversation_id"] + message_count = 0 + context_count = 0 + conversation_count = 0 + if isinstance(conversation_id, str) and conversation_id: + message_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_messages WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + context_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_context_entries WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + conversation_count = int(conn.execute( + "SELECT COUNT(*) FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ).fetchone()[0]) + conn.execute( + "DELETE FROM chat_conversations WHERE workspace_id=? AND conversation_id=?", + (workspace_id, conversation_id), + ) + cursor = conn.execute( + "DELETE FROM imported_sessions WHERE workspace_id=? AND session_id=?", + (workspace_id, session_id), + ) + session_count = int(cursor.rowcount) + return { + "workspace_id": workspace_id, + "session_id": session_id, + "affected_count": session_count + conversation_count + message_count + context_count, + "deleted_session_count": session_count, + "deleted_conversation_count": conversation_count, + "deleted_message_count": message_count, + "deleted_context_count": context_count, + } + + def _delete_by_id(self, table: str, field: str, workspace_id: str, identifier: str) -> dict[str, Any]: + if table not in {"chat_messages", "chat_context_entries", "imported_sessions"}: + raise TranscriptStoreError("Unsupported delete target.") + workspace_id = _require_id(workspace_id, "workspace_id") + identifier = _require_id(identifier, field) + with self._write_lock, self._connection(write=True) as conn: + cursor = conn.execute( + f"DELETE FROM {table} WHERE workspace_id=? AND {field}=?", + (workspace_id, identifier), + ) + return {"workspace_id": workspace_id, field: identifier, "affected_count": int(cursor.rowcount)} + + +class WorkspaceTranscriptService: + """Non-admin facade fixed to one immutable Workspace scope.""" + + def __init__(self, store: TranscriptStore, scope: WorkspaceScope) -> None: + self.store = store + self.scope = scope + + def record_messages(self, conversation_id: str, messages: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: + return self.store.record_messages(self.scope.workspace_id, conversation_id, messages, **kwargs) + + def record_context(self, conversation_id: str, entries: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: + return self.store.record_context(self.scope.workspace_id, conversation_id, entries, **kwargs) + + def list_conversations(self, **kwargs: Any) -> dict[str, Any]: + return self.store.list_conversations(self.scope.workspace_id, **kwargs) + + def conversation_detail(self, conversation_id: str, **kwargs: Any) -> dict[str, Any] | None: + return self.store.conversation_detail(self.scope.workspace_id, conversation_id, **kwargs) + + +def _decode_metadata(item: dict[str, Any]) -> dict[str, Any]: + raw = item.pop("metadata_json", "{}") + try: + item["metadata"] = json.loads(raw) + except json.JSONDecodeError: + item["metadata"] = {"parse_error": True} + return item + + +__all__ = [ + "MAX_PAGE_SIZE", + "TranscriptStore", + "TranscriptStoreError", + "WorkspaceScope", + "WorkspaceTranscriptService", +] diff --git a/coding_tools_mcp/transport_http.py b/coding_tools_mcp/transport_http.py index 698d082..191e5e3 100644 --- a/coding_tools_mcp/transport_http.py +++ b/coding_tools_mcp/transport_http.py @@ -26,14 +26,14 @@ class HTTPSessionRecord: class HTTPSessionManager: """Own independent Runtime instances for Streamable HTTP sessions.""" - def __init__(self, factory: Callable[[], Any]) -> None: + def __init__(self, factory: Callable[[Any], Any]) -> None: self._factory = factory self._sessions: dict[str, HTTPSessionRecord] = {} self._lock = threading.Lock() self._creating = 0 self._closed = False - def create(self) -> Any: + def create(self, context: Any) -> Any: self.prune() with self._lock: if self._closed: @@ -44,7 +44,7 @@ def create(self) -> Any: runtime: Any | None = None installed = False try: - runtime = self._factory() + runtime = self._factory(context) record = HTTPSessionRecord(runtime=runtime, last_seen=time.time()) with self._lock: if self._closed: diff --git a/coding_tools_mcp/upstream.py b/coding_tools_mcp/upstream.py new file mode 100644 index 0000000..580ed1f --- /dev/null +++ b/coding_tools_mcp/upstream.py @@ -0,0 +1,1181 @@ +from __future__ import annotations + +import atexit +import copy +import json +import os +import queue +import re +import signal +import socket +import subprocess +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from collections import deque +from collections.abc import Callable, Collection, Iterable +from dataclasses import dataclass, field +from http.client import RemoteDisconnected +from pathlib import Path +from typing import Any + + +DEFAULT_PROTOCOL_VERSION = "2025-11-25" +DEFAULT_TIMEOUT_MS = 30_000 +MAX_RESPONSE_BYTES = 1_048_576 +MAX_TOOL_NAME_CHARS = 512 +ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") +FORBIDDEN_STDIO_COMMANDS = { + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + "bash", + "bash.exe", + "sh", + "sh.exe", + "zsh", + "zsh.exe", + "fish", + "fish.exe", +} +SHELL_FRAGMENT_RE = re.compile(r"(\|\||&&|[|<>;`]|\$\(|\$\{)") +UPSTREAM_BASE_ENV_NAMES = frozenset( + { + "PATH", + "PATHEXT", + "COMSPEC", + "SYSTEMROOT", + "WINDIR", + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "TEMP", + "TMP", + "LANG", + "LC_ALL", + "TERM", + } +) + + +class UpstreamConfigError(ValueError): + """Gateway configuration cannot safely form a fixed tool snapshot.""" + + +class UpstreamError(Exception): + def __init__( + self, + code: str, + message: str, + *, + category: str = "runtime", + retryable: bool = False, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.category = category + self.retryable = retryable + self.details = details or {} + + +@dataclass(frozen=True) +class UpstreamServerConfig: + alias: str + transport: str + enabled: bool = True + url: str | None = None + command: str | None = None + args: tuple[str, ...] = () + env: dict[str, Any] = field(default_factory=dict) + headers: dict[str, str] = field(default_factory=dict) + authorization_env: str | None = None + include_tools: tuple[str, ...] = () + exclude_tools: tuple[str, ...] = () + timeout_ms: int = DEFAULT_TIMEOUT_MS + + +@dataclass(frozen=True) +class UpstreamConfigSnapshot: + """Configuration, enable state, and allowlists fixed before Runtime creation.""" + + configs: tuple[UpstreamServerConfig, ...] = () + source: str | None = None + + @classmethod + def empty(cls) -> "UpstreamConfigSnapshot": + return cls() + + +@dataclass(frozen=True) +class UpstreamTool: + public_name: str + remote_name: str + definition: dict[str, Any] + + +@dataclass +class UpstreamStatus: + alias: str + transport: str + enabled: bool + initialized: bool = False + tool_count: int = 0 + error: dict[str, Any] | None = None + target: str | None = None + + def payload(self) -> dict[str, Any]: + result: dict[str, Any] = { + "alias": self.alias, + "transport": self.transport, + "enabled": self.enabled, + "initialized": self.initialized, + "tool_count": self.tool_count, + } + if self.target is not None: + result["target"] = self.target + if self.error is not None: + result["error"] = copy.deepcopy(self.error) + return result + + +class BaseUpstreamClient: + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, + ) -> None: + self.config = config + self.protocol_version = protocol_version + self.secret_resolver = secret_resolver + self._next_id = 1 + self._id_lock = threading.Lock() + + def initialize(self) -> None: + self.request( + "initialize", + { + "protocolVersion": self.protocol_version, + "capabilities": {}, + "clientInfo": {"name": "coding-tools-mcp-upstream", "version": "0"}, + }, + ) + self.notify("notifications/initialized", {}) + + def list_tools(self) -> list[dict[str, Any]]: + response = self.request("tools/list", {}) + tools = response.get("tools") if isinstance(response, dict) else None + if not isinstance(tools, list): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/list response did not include a tools list.", + category="protocol", + ) + if not all(isinstance(tool, dict) for tool in tools): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/list contained a non-object tool definition.", + category="protocol", + ) + return [copy.deepcopy(tool) for tool in tools] + + def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + response = self.request("tools/call", {"name": name, "arguments": arguments}) + return normalize_tool_result(response) + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + raise NotImplementedError + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + raise NotImplementedError + + def close(self) -> None: + return None + + def _next_request_id(self) -> int: + with self._id_lock: + request_id = self._next_id + self._next_id += 1 + return request_id + + +def _rpc_result(response: Any, request_id: int, method: str) -> dict[str, Any]: + if not isinstance(response, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response was not a JSON object.", + category="protocol", + details={"method": method}, + ) + if response.get("jsonrpc") != "2.0": + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response did not use JSON-RPC 2.0.", + category="protocol", + details={"method": method}, + ) + if response.get("id") != request_id: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response id did not match the request id.", + category="protocol", + details={"method": method}, + ) + has_result = "result" in response + has_error = "error" in response + if has_result == has_error: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response must contain exactly one of result or error.", + category="protocol", + details={"method": method}, + ) + if has_error: + error = response.get("error") + if not isinstance(error, dict) or not isinstance(error.get("message"), str): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream JSON-RPC error envelope was invalid.", + category="protocol", + details={"method": method}, + ) + raise UpstreamError( + "UPSTREAM_RPC_ERROR", + error["message"], + category="upstream", + details={"method": method, "rpc_error": copy.deepcopy(error)}, + ) + result = response.get("result") + if not isinstance(result, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response result was not an object.", + category="protocol", + details={"method": method}, + ) + return result + + +class HttpUpstreamClient(BaseUpstreamClient): + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, + ) -> None: + super().__init__(config, protocol_version, secret_resolver=secret_resolver) + if not config.url: + raise UpstreamConfigError( + f"Upstream {config.alias!r} requires url for streamable_http transport." + ) + self.url = config.url + self.session_id: str | None = None + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + request_id = self._next_request_id() + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + response = self._send(payload, expect_response=True) + return _rpc_result(response, request_id, method) + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + self._send(payload, expect_response=False) + + def _send(self, payload: dict[str, Any], *, expect_response: bool) -> dict[str, Any] | None: + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") + headers = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + **self.config.headers, + } + if self.session_id: + headers["Mcp-Session-Id"] = self.session_id + token = os.environ.get(self.config.authorization_env) if self.config.authorization_env else None + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(self.url, data=data, headers=headers, method="POST") + timeout_s = max(self.config.timeout_ms, 1) / 1000 + try: + with urllib.request.urlopen(request, timeout=timeout_s) as response: + session_id = response.headers.get("Mcp-Session-Id") + if session_id: + self.session_id = session_id + if not expect_response or response.status in {202, 204}: + return None + raw = _read_bounded_response(response) + expected_id = payload.get("id") + return decode_http_rpc_response( + raw, + response.headers.get("Content-Type", ""), + expected_id=expected_id if isinstance(expected_id, int) else None, + ) + except urllib.error.HTTPError as exc: + raw = exc.read(MAX_RESPONSE_BYTES + 1) + if len(raw) <= MAX_RESPONSE_BYTES and raw: + try: + return decode_http_rpc_response( + raw, + exc.headers.get("Content-Type", ""), + expected_id=payload.get("id") if isinstance(payload.get("id"), int) else None, + ) + except (UpstreamError, UnicodeDecodeError, json.JSONDecodeError): + pass + raise UpstreamError( + "UPSTREAM_HTTP_ERROR", + f"Upstream MCP server returned HTTP {exc.code}.", + category="upstream", + retryable=500 <= exc.code < 600, + details={"status": exc.code}, + ) from exc + except (TimeoutError, socket.timeout) as exc: + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) from exc + except urllib.error.URLError as exc: + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) from exc + raise UpstreamError( + "UPSTREAM_CONNECTION_FAILED", + "Could not connect to upstream MCP server.", + retryable=True, + ) from exc + except (RemoteDisconnected, ConnectionError, BrokenPipeError, ConnectionResetError) as exc: + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP server disconnected.", + retryable=True, + ) from exc + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream returned invalid JSON.", + category="protocol", + ) from exc + + +class StdioUpstreamClient(BaseUpstreamClient): + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, + ) -> None: + super().__init__(config, protocol_version, secret_resolver=secret_resolver) + if not config.command: + raise UpstreamConfigError( + f"Upstream {config.alias!r} requires command for stdio transport." + ) + self._lock = threading.Lock() + self._responses: queue.Queue[dict[str, Any] | UpstreamError] = queue.Queue() + self._stderr_lines: deque[str] = deque(maxlen=500) + self._stderr_lock = threading.Lock() + env = base_upstream_environment() + env.update(resolve_env_config(config.env, secret_resolver=self.secret_resolver)) + creationflags = 0 + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + self.process = subprocess.Popen( + [config.command, *config.args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="strict", + bufsize=1, + env=env, + creationflags=creationflags, + ) + self._stdout_thread = threading.Thread(target=self._read_stdout, daemon=True) + self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) + self._stdout_thread.start() + self._stderr_thread.start() + atexit.register(self.close) + + def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + request_id = self._next_request_id() + payload: dict[str, Any] = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + with self._lock: + self._write(payload) + deadline = time.monotonic() + max(self.config.timeout_ms, 1) / 1000 + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) + if self.process.poll() is not None and self._responses.empty(): + raise self._process_exited_error() + try: + response = self._responses.get(timeout=min(remaining, 0.1)) + except queue.Empty: + continue + if isinstance(response, UpstreamError): + raise response + return _rpc_result(response, request_id, method) + + def notify(self, method: str, params: dict[str, Any] | None = None) -> None: + payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method} + if params is not None: + payload["params"] = params + with self._lock: + self._write(payload) + + def close(self) -> None: + process = getattr(self, "process", None) + if process is None or process.poll() is not None: + return + try: + if os.name == "nt": + process.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined] + else: + process.terminate() + process.wait(timeout=2) + except Exception: # noqa: BLE001 + try: + process.kill() + except Exception: # noqa: BLE001 + pass + + def _write(self, payload: dict[str, Any]) -> None: + if self.process.poll() is not None: + raise self._process_exited_error() + if self.process.stdin is None: + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP stdin is closed.", + retryable=True, + ) + try: + self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + self.process.stdin.flush() + except OSError as exc: + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP stdio process disconnected.", + retryable=True, + ) from exc + + def _read_stdout(self) -> None: + if self.process.stdout is None: + return + try: + for raw_line in self.process.stdout: + line = raw_line.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + self._responses.put( + UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream stdio returned invalid JSON.", + category="protocol", + ) + ) + continue + if not isinstance(parsed, dict): + self._responses.put( + UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream stdio response was not a JSON object.", + category="protocol", + ) + ) + continue + if "id" not in parsed and isinstance(parsed.get("method"), str): + continue + self._responses.put(parsed) + except UnicodeError: + self._responses.put( + UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream stdio returned non-UTF-8 output.", + category="protocol", + ) + ) + + def _read_stderr(self) -> None: + if self.process.stderr is None: + return + for line in self.process.stderr: + item = line.rstrip("\r\n")[:500] + with self._stderr_lock: + self._stderr_lines.append(item) + + def _process_exited_error(self) -> UpstreamError: + with self._stderr_lock: + stderr_tail = list(self._stderr_lines)[-5:] + return UpstreamError( + "UPSTREAM_PROCESS_EXITED", + "Upstream MCP stdio process exited.", + retryable=True, + details={"returncode": self.process.returncode, "stderr_tail": stderr_tail}, + ) + + +class UpstreamManager: + """Per-Runtime upstream clients with an immutable discovered tool snapshot.""" + + def __init__( + self, + configs: Iterable[UpstreamServerConfig], + *, + protocol_version: str = DEFAULT_PROTOCOL_VERSION, + secret_resolver: Callable[[str], str] | None = None, + reserved_names: Collection[str] = (), + ) -> None: + self.protocol_version = protocol_version + self.secret_resolver = secret_resolver + self.configs = tuple(configs) + self.clients: dict[str, BaseUpstreamClient] = {} + self.statuses: dict[str, UpstreamStatus] = {} + self._tools: dict[str, UpstreamTool] = {} + self._tool_order: tuple[str, ...] = () + self._closed = False + try: + self._initialize_configs(frozenset(reserved_names)) + except BaseException: + self.close() + raise + + @classmethod + def empty( + cls, + protocol_version: str = DEFAULT_PROTOCOL_VERSION, + *, + reserved_names: Collection[str] = (), + ) -> "UpstreamManager": + return cls((), protocol_version=protocol_version, reserved_names=reserved_names) + + @classmethod + def from_snapshot( + cls, + snapshot: UpstreamConfigSnapshot, + *, + protocol_version: str = DEFAULT_PROTOCOL_VERSION, + secret_resolver: Callable[[str], str] | None = None, + reserved_names: Collection[str] = (), + ) -> "UpstreamManager": + return cls( + snapshot.configs, + protocol_version=protocol_version, + secret_resolver=secret_resolver, + reserved_names=reserved_names, + ) + + def tool_definitions(self) -> list[dict[str, Any]]: + return [copy.deepcopy(self._tools[name].definition) for name in self._tool_order] + + def tool_names(self) -> list[str]: + return list(self._tool_order) + + def has_tool(self, name: str) -> bool: + return name in self._tools + + def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + tool = self._tools.get(name) + if tool is None: + return upstream_error_result( + "UPSTREAM_TOOL_NOT_FOUND", + f"Unknown upstream tool: {name}", + category="validation", + ) + if self._closed: + return upstream_error_result( + "UPSTREAM_DISCONNECTED", + "Upstream Gateway is closed.", + retryable=True, + alias=name.partition("__")[0], + tool_name=name, + ) + alias, _separator, _remote = name.partition("__") + client = self.clients.get(alias) + if client is None: + return upstream_error_result( + "UPSTREAM_NOT_AVAILABLE", + f"Upstream {alias!r} is not available.", + retryable=True, + alias=alias, + tool_name=name, + ) + try: + return client.call_tool(tool.remote_name, arguments or {}) + except UpstreamError as exc: + return upstream_error_result( + exc.code, + exc.message, + category=exc.category, + retryable=exc.retryable, + details=exc.details, + alias=alias, + tool_name=name, + ) + except OSError: + return upstream_error_result( + "UPSTREAM_DISCONNECTED", + "Upstream MCP server disconnected.", + retryable=True, + alias=alias, + tool_name=name, + ) + + def status_payload(self) -> dict[str, Any]: + statuses = [self.statuses[alias].payload() for alias in sorted(self.statuses)] + return { + "enabled": any(status.enabled for status in self.statuses.values()), + "server_count": len(self.statuses), + "initialized_count": sum(1 for status in self.statuses.values() if status.initialized), + "tool_count": len(self._tool_order), + "snapshot_immutable": True, + "remote_capability_boundary": "upstream_server", + "servers": statuses, + } + + def close(self) -> None: + if self._closed: + return + self._closed = True + for client in list(self.clients.values()): + client.close() + self.clients.clear() + + def _initialize_configs(self, reserved_names: frozenset[str]) -> None: + seen_public_names = set(reserved_names) + ordered_names: list[str] = [] + for config in self.configs: + status = UpstreamStatus( + alias=config.alias, + transport=config.transport, + enabled=config.enabled, + target=safe_target(config), + ) + self.statuses[config.alias] = status + if not config.enabled: + continue + client: BaseUpstreamClient | None = None + try: + client = build_client( + config, + self.protocol_version, + secret_resolver=self.secret_resolver, + ) + client.initialize() + raw_tools = filter_tools(client.list_tools(), config) + registered: list[UpstreamTool] = [] + for raw_tool in raw_tools: + remote_name = raw_tool.get("name") + if not isinstance(remote_name, str) or not remote_name: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool definition had no valid name.", + category="protocol", + ) + public_name = namespaced_tool_name(config.alias, remote_name) + if public_name in seen_public_names: + raise UpstreamConfigError( + f"Upstream tool namespace collision: {public_name!r}." + ) + seen_public_names.add(public_name) + registered.append( + UpstreamTool( + public_name=public_name, + remote_name=remote_name, + definition=namespaced_tool_definition(public_name, raw_tool), + ) + ) + self.clients[config.alias] = client + for tool in registered: + self._tools[tool.public_name] = tool + ordered_names.append(tool.public_name) + status.initialized = True + status.tool_count = len(registered) + except UpstreamConfigError: + if client is not None: + client.close() + raise + except (OSError, UpstreamError) as exc: + if client is not None: + client.close() + status.error = error_payload(exc) + self._tool_order = tuple(ordered_names) + + +def load_upstream_config_snapshot(path: str | Path) -> UpstreamConfigSnapshot: + config_path = Path(path).expanduser() + try: + text = config_path.read_text(encoding="utf-8") + except OSError as exc: + raise UpstreamConfigError(f"Could not read upstream config {str(config_path)!r}: {exc}") from exc + try: + raw = json.loads(text, object_pairs_hook=_reject_duplicate_keys) + except json.JSONDecodeError as exc: + raise UpstreamConfigError( + f"Upstream config {str(config_path)!r} is not valid JSON: {exc}" + ) from exc + if not isinstance(raw, dict): + raise UpstreamConfigError("Upstream config must be a JSON object.") + servers = raw.get("servers", raw) + if not isinstance(servers, dict): + raise UpstreamConfigError("Upstream config must contain a servers object.") + configs = tuple(parse_server_config(alias, value) for alias, value in servers.items()) + return UpstreamConfigSnapshot(configs=configs, source=str(config_path.resolve(strict=False))) + + +def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise UpstreamConfigError(f"Duplicate JSON key in upstream config: {key!r}.") + result[key] = value + return result + + +def parse_server_config(alias: str, value: Any) -> UpstreamServerConfig: + if not isinstance(alias, str) or not ALIAS_RE.fullmatch(alias) or "__" in alias: + raise UpstreamConfigError( + f"Invalid upstream alias {alias!r}. Use 1-64 letters, digits, underscores, or hyphens; '__' is reserved." + ) + if not isinstance(value, dict): + raise UpstreamConfigError(f"Upstream {alias!r} config must be an object.") + transport = str(value.get("transport") or "streamable_http") + if transport == "http": + transport = "streamable_http" + if transport not in {"streamable_http", "stdio"}: + raise UpstreamConfigError( + f"Upstream {alias!r} transport must be streamable_http or stdio." + ) + enabled = value.get("enabled", True) + if not isinstance(enabled, bool): + raise UpstreamConfigError(f"Upstream {alias!r} enabled must be a boolean.") + try: + timeout_ms = int(value.get("timeout_ms") or DEFAULT_TIMEOUT_MS) + except (TypeError, ValueError) as exc: + raise UpstreamConfigError(f"Upstream {alias!r} timeout_ms must be positive.") from exc + if timeout_ms <= 0: + raise UpstreamConfigError(f"Upstream {alias!r} timeout_ms must be positive.") + command = _optional_str(value.get("command")) + args = _string_tuple(value.get("args"), field_name="args", alias=alias) + if transport == "stdio": + validate_stdio_launch(alias, command, args) + elif not _optional_str(value.get("url")): + raise UpstreamConfigError( + f"Upstream {alias!r} requires url for streamable_http transport." + ) + include_tools = _string_tuple( + value.get("include_tools"), field_name="include_tools", alias=alias + ) + exclude_tools = _string_tuple( + value.get("exclude_tools"), field_name="exclude_tools", alias=alias + ) + overlap = sorted(set(include_tools) & set(exclude_tools)) + if overlap: + raise UpstreamConfigError( + f"Upstream {alias!r} cannot include and exclude the same tools: {', '.join(overlap)}." + ) + return UpstreamServerConfig( + alias=alias, + transport=transport, + enabled=enabled, + url=_optional_str(value.get("url")), + command=command, + args=args, + env=_env_dict(value.get("env"), field_name="env", alias=alias), + headers=_string_dict(value.get("headers"), field_name="headers", alias=alias), + authorization_env=_optional_str(value.get("authorization_env")), + include_tools=include_tools, + exclude_tools=exclude_tools, + timeout_ms=timeout_ms, + ) + + +def build_client( + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: Callable[[str], str] | None = None, +) -> BaseUpstreamClient: + if config.transport == "stdio": + return StdioUpstreamClient(config, protocol_version, secret_resolver=secret_resolver) + return HttpUpstreamClient(config, protocol_version, secret_resolver=secret_resolver) + + +def filter_tools( + tools: list[dict[str, Any]], config: UpstreamServerConfig +) -> list[dict[str, Any]]: + included = set(config.include_tools) + excluded = set(config.exclude_tools) + result: list[dict[str, Any]] = [] + for tool in tools: + name = tool.get("name") + if not isinstance(name, str): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool definition had a non-string name.", + category="protocol", + ) + if included and name not in included: + continue + if name in excluded: + continue + result.append(tool) + return result + + +def namespaced_tool_name(alias: str, remote_name: str) -> str: + if not remote_name or any(ord(char) < 32 for char in remote_name): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool name was empty or contained control characters.", + category="protocol", + ) + public_name = f"{alias}__{remote_name}" + if len(public_name) > MAX_TOOL_NAME_CHARS: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Namespaced upstream tool name exceeded the supported length.", + category="protocol", + ) + return public_name + + +def namespaced_tool_definition( + public_name: str, tool: dict[str, Any] +) -> dict[str, Any]: + input_schema = tool.get("inputSchema") + if not isinstance(input_schema, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool definition did not contain an object inputSchema.", + category="protocol", + ) + annotations = tool.get("annotations") + if annotations is not None and not isinstance(annotations, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool annotations were not an object.", + category="protocol", + ) + output_schema = tool.get("outputSchema") + if output_schema is not None and not isinstance(output_schema, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tool outputSchema was not an object.", + category="protocol", + ) + definition = copy.deepcopy(tool) + definition["name"] = public_name + return definition + + +def normalize_tool_result(result: dict[str, Any]) -> dict[str, Any]: + if not isinstance(result, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/call result was not an object.", + category="protocol", + ) + normalized = copy.deepcopy(result) + content = normalized.get("content") + if content is None: + normalized["content"] = [] + elif not isinstance(content, list) or not all(isinstance(item, dict) for item in content): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/call content was not an array of content objects.", + category="protocol", + ) + is_error = normalized.get("isError") + if is_error is None: + normalized["isError"] = False + elif not isinstance(is_error, bool): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream tools/call isError was not a boolean.", + category="protocol", + ) + return normalized + + +def upstream_error_result( + code: str, + message: str, + *, + category: str = "runtime", + retryable: bool = False, + details: dict[str, Any] | None = None, + alias: str | None = None, + tool_name: str | None = None, +) -> dict[str, Any]: + error: dict[str, Any] = { + "code": code, + "message": message, + "category": category, + "retryable": retryable, + "details": copy.deepcopy(details or {}), + } + payload: dict[str, Any] = {"ok": False, "error": error} + if alias is not None: + payload["upstream_alias"] = alias + if tool_name is not None: + payload["tool_name"] = tool_name + return { + "content": [{"type": "text", "text": message}], + "structuredContent": payload, + "isError": True, + } + + +def _read_bounded_response(response: Any) -> bytes: + raw = response.read(MAX_RESPONSE_BYTES + 1) + if len(raw) > MAX_RESPONSE_BYTES: + raise UpstreamError( + "UPSTREAM_RESPONSE_TOO_LARGE", + "Upstream response exceeded the maximum supported size.", + category="protocol", + ) + return raw + + +def decode_http_rpc_response( + raw: bytes, + content_type: str, + *, + expected_id: int | None = None, +) -> dict[str, Any]: + text = raw.decode("utf-8") + if "text/event-stream" not in content_type.lower(): + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream HTTP response JSON was not an object.", + category="protocol", + ) + events: list[str] = [] + current: list[str] = [] + for line in text.splitlines(): + if not line: + if current: + events.append("\n".join(current)) + current = [] + continue + if line.startswith("data:"): + current.append(line.removeprefix("data:").lstrip()) + if current: + events.append("\n".join(current)) + if not events: + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream SSE response did not include data events.", + category="protocol", + ) + candidates: list[dict[str, Any]] = [] + for event in events: + parsed = json.loads(event) + if not isinstance(parsed, dict): + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream SSE data was not a JSON object.", + category="protocol", + ) + candidates.append(parsed) + if expected_id is not None: + for candidate in candidates: + if candidate.get("id") == expected_id: + return candidate + return candidates[0] + + +def safe_target(config: UpstreamServerConfig) -> str | None: + if config.transport == "stdio": + command = config.command or "" + args = " ".join(config.args[:3]) + suffix = " ..." if len(config.args) > 3 else "" + return f"{command} {args}{suffix}".strip() + if not config.url: + return None + parsed = urllib.parse.urlsplit(config.url) + redacted = parsed._replace(query="", fragment="") + return urllib.parse.urlunsplit(redacted) + + +def validate_stdio_launch(alias: str, command: str | None, args: tuple[str, ...]) -> None: + if not command or not command.strip(): + raise UpstreamConfigError(f"Upstream {alias!r} requires command for stdio transport.") + command_text = command.strip() + if "\n" in command_text or "\r" in command_text: + raise UpstreamConfigError( + f"Upstream {alias!r} command must be a single executable path or name." + ) + first_word = command_text.split()[0].strip('"\'').lower() + leaf = command_text.strip('"\'').replace("\\", "/").rsplit("/", 1)[-1].lower() + if first_word in FORBIDDEN_STDIO_COMMANDS or leaf in FORBIDDEN_STDIO_COMMANDS: + raise UpstreamConfigError( + f"Upstream {alias!r} command cannot be a shell interpreter." + ) + if SHELL_FRAGMENT_RE.search(command_text): + raise UpstreamConfigError( + f"Upstream {alias!r} command cannot contain shell control syntax." + ) + for index, arg in enumerate(args): + if "\n" in arg or "\r" in arg or SHELL_FRAGMENT_RE.search(arg): + raise UpstreamConfigError( + f"Upstream {alias!r} args[{index}] cannot contain shell control syntax." + ) + + +def base_upstream_environment() -> dict[str, str]: + return { + name: value + for name, value in os.environ.items() + if name.upper() in UPSTREAM_BASE_ENV_NAMES + } + + +def resolve_env_config( + env_config: dict[str, Any], + *, + secret_resolver: Callable[[str], str] | None = None, +) -> dict[str, str]: + resolved: dict[str, str] = {} + for name, value in env_config.items(): + if isinstance(value, str): + resolved[name] = value + continue + if not isinstance(value, dict): + raise UpstreamConfigError( + f"Environment value for {name!r} must be a string or reference object." + ) + env_ref = value.get("env_ref") + secret_ref = value.get("secret_ref") + if isinstance(env_ref, str) and env_ref: + resolved[name] = os.environ.get(env_ref, "") + continue + if isinstance(secret_ref, str) and secret_ref: + if secret_resolver is None: + raise UpstreamConfigError("secret_ref requires a configured secret resolver.") + resolved[name] = secret_resolver(secret_ref) + continue + raise UpstreamConfigError( + f"Environment reference for {name!r} must contain env_ref or secret_ref." + ) + return resolved + + +def error_payload(exc: BaseException) -> dict[str, Any]: + if isinstance(exc, UpstreamError): + payload: dict[str, Any] = { + "code": exc.code, + "message": exc.message, + "category": exc.category, + "retryable": exc.retryable, + } + if exc.details: + payload["details"] = copy.deepcopy(exc.details) + return payload + if isinstance(exc, UpstreamConfigError): + return { + "code": "UPSTREAM_CONFIG_INVALID", + "message": str(exc), + "category": "configuration", + "retryable": False, + } + return { + "code": "UPSTREAM_INITIALIZATION_FAILED", + "message": str(exc), + "category": "runtime", + "retryable": True, + } + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise UpstreamConfigError("Expected string value.") + return value + + +def _string_tuple(value: Any, *, field_name: str, alias: str) -> tuple[str, ...]: + if value is None: + return () + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must be a list of strings." + ) + if len(set(value)) != len(value): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must not contain duplicates." + ) + return tuple(value) + + +def _string_dict(value: Any, *, field_name: str, alias: str) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict) or not all( + isinstance(key, str) and isinstance(item, str) for key, item in value.items() + ): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must be an object with string keys and values." + ) + return dict(value) + + +def _env_dict(value: Any, *, field_name: str, alias: str) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must be an object." + ) + result: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name} must use string keys." + ) + if isinstance(item, str): + result[key] = item + continue + if isinstance(item, dict) and len(item) == 1: + ref_key, ref_value = next(iter(item.items())) + if ref_key in {"env_ref", "secret_ref"} and isinstance(ref_value, str) and ref_value: + result[key] = {ref_key: ref_value} + continue + raise UpstreamConfigError( + f"Upstream {alias!r} field {field_name}.{key} must be a string, env_ref, or secret_ref." + ) + return result diff --git a/coding_tools_mcp/webui.py b/coding_tools_mcp/webui.py new file mode 100644 index 0000000..e1df81a --- /dev/null +++ b/coding_tools_mcp/webui.py @@ -0,0 +1,37 @@ +"""Packaged Admin WebUI entry points. + +``webui/src/**`` is the only editable frontend source. The packaged HTML is +created by ``npm --prefix webui run build`` and is intentionally self-contained +so the authenticated ``/admin`` route does not need a second static-file router. +""" + +from __future__ import annotations + +from pathlib import Path + +WEBUI_DIST = Path(__file__).with_name("webui_dist") +ADMIN_HTML = WEBUI_DIST / "admin.html" + + +def admin_console_html() -> str: + try: + return ADMIN_HTML.read_text(encoding="utf-8") + except OSError: + return """ + + + + + MCP Admin Console + + +
+

MCP Admin Console

+

The generated WebUI artifact is missing.

+

Run npm --prefix webui run build from the repository root.

+
+ +""" + + +__all__ = ["ADMIN_HTML", "WEBUI_DIST", "admin_console_html"] diff --git a/coding_tools_mcp/webui_dist/admin.html b/coding_tools_mcp/webui_dist/admin.html new file mode 100644 index 0000000..89c134a --- /dev/null +++ b/coding_tools_mcp/webui_dist/admin.html @@ -0,0 +1,1147 @@ + + + + + + + Coding Tools MCP Admin + + + + +
+
+

coding-tools-mcp

+

Admin Console

+
+
+
+ + +

仅保存在当前页面内存中;不会写入 URL、浏览器持久化存储、日志或服务器设置。

+
+ + +
+
+ +
+ + +
+
输入专用 Admin token 后连接。
+ +
+

Runtime

概览

+
+

Admin API

未连接

普通 MCP bearer 不具备管理员权限。

+

Gateway

未连接

工具快照在 Runtime 初始化时冻结,不支持热 reload。

+

Telemetry

未报告

+

Vault

未连接

页面从不读取或显示 Secret 值。

+
+
+ + + + + + + + + + + + +
+
+ + +
+

确认操作

+

+
+
+
+ + + + + + + + diff --git a/coding_tools_mcp/workspace_binding.py b/coding_tools_mcp/workspace_binding.py new file mode 100644 index 0000000..7d932f9 --- /dev/null +++ b/coding_tools_mcp/workspace_binding.py @@ -0,0 +1,85 @@ +"""Immutable Workspace selection for stdio and Streamable HTTP runtimes.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from pathlib import Path + +from .oauth import OAuthIdentity +from .workspace_catalog import WorkspaceCatalog, WorkspaceCatalogError + + +class WorkspaceBindingError(RuntimeError): + """A request identity cannot be safely bound to an enabled Workspace.""" + + +@dataclass(frozen=True) +class WorkspaceBinding: + workspace_id: str + root: Path + authorization_method: str + client_id: str | None = None + grant_id: str | None = None + + def authorization_key(self) -> tuple[str, str | None, str | None, str]: + return ( + self.authorization_method, + self.client_id, + self.grant_id, + self.workspace_id, + ) + + +class WorkspaceBindingResolver: + """Resolve a startup Catalog snapshot into immutable per-Runtime bindings. + + Replacing the Catalog affects only future resolutions. Existing Runtime + instances retain their frozen binding and Workspace adapter until closed. + """ + + def __init__(self, catalog: WorkspaceCatalog) -> None: + self._catalog = catalog + self._lock = threading.Lock() + + def catalog(self) -> WorkspaceCatalog: + with self._lock: + return self._catalog + + def update_catalog(self, catalog: WorkspaceCatalog) -> None: + with self._lock: + self._catalog = catalog + + def resolve_stdio(self) -> WorkspaceBinding: + catalog = self.catalog() + entry = catalog.default() + return WorkspaceBinding(entry.id, entry.root, "stdio") + + def resolve_http( + self, + authorization_method: str, + identity: OAuthIdentity | None, + ) -> WorkspaceBinding: + catalog = self.catalog() + if authorization_method == "oauth": + if identity is None: + raise WorkspaceBindingError("OAuth identity is required for Workspace binding.") + try: + entry = catalog.get(identity.workspace_id) + except WorkspaceCatalogError as exc: + raise WorkspaceBindingError( + "OAuth identity has no enabled Workspace mapping." + ) from exc + return WorkspaceBinding( + entry.id, + entry.root, + "oauth", + client_id=identity.client_id, + grant_id=identity.grant_id, + ) + if identity is not None: + raise WorkspaceBindingError( + "OAuth identity cannot be combined with a non-OAuth authorization method." + ) + entry = catalog.default() + return WorkspaceBinding(entry.id, entry.root, authorization_method) diff --git a/coding_tools_mcp/workspace_catalog.py b/coding_tools_mcp/workspace_catalog.py new file mode 100644 index 0000000..f9c3f5e --- /dev/null +++ b/coding_tools_mcp/workspace_catalog.py @@ -0,0 +1,206 @@ +"""Validated catalog of independently selectable workspace roots.""" + +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + + +WORKSPACE_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z") + + +class WorkspaceCatalogError(ValueError): + pass + + +@dataclass(frozen=True) +class WorkspaceEntry: + id: str + name: str + root: Path + enabled: bool = True + default: bool = False + + def payload(self) -> dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "root": str(self.root), + "enabled": self.enabled, + "default": self.default, + } + + +class WorkspaceCatalog: + def __init__(self, entries: list[WorkspaceEntry], default_id: str) -> None: + if not entries: + raise WorkspaceCatalogError("Workspace catalog must contain at least one workspace.") + if not isinstance(default_id, str) or not default_id: + raise WorkspaceCatalogError("Workspace catalog requires a default workspace id.") + + normalized = [ + WorkspaceEntry( + id=_validated_id(entry.id), + name=_validated_name(entry.name), + root=_validated_root(entry.root), + enabled=bool(entry.enabled), + default=bool(entry.default), + ) + for entry in entries + ] + default_flags = [entry.id for entry in normalized if entry.default] + if len(default_flags) > 1: + raise WorkspaceCatalogError("Workspace catalog must contain only one default workspace.") + if default_flags and default_flags[0] != default_id: + raise WorkspaceCatalogError("Default workspace flag conflicts with default_workspace_id.") + + by_id = {entry.id: entry for entry in normalized} + if len(by_id) != len(normalized): + raise WorkspaceCatalogError("Workspace IDs must be unique.") + if default_id not in by_id: + raise WorkspaceCatalogError("Default workspace id is not present in the catalog.") + if not by_id[default_id].enabled: + raise WorkspaceCatalogError("The default workspace must be enabled.") + + canonical = [replace(entry, default=entry.id == default_id) for entry in normalized] + self._validate_roots(canonical) + self.entries = tuple(canonical) + self.default_id = default_id + self._by_id = {entry.id: entry for entry in canonical} + + @classmethod + def single(cls, root: str | Path) -> "WorkspaceCatalog": + resolved = _validated_root(root) + identifier = "ws-" + hashlib.sha256(_root_identity(resolved).encode("utf-8")).hexdigest()[:16] + return cls( + [WorkspaceEntry(identifier, resolved.name or str(resolved), resolved, True, True)], + identifier, + ) + + @classmethod + def from_settings(cls, settings: dict[str, Any], fallback_root: str | Path) -> "WorkspaceCatalog": + raw = settings.get("workspace_catalog") + explicit_default = settings.get("default_workspace_id") + if not isinstance(raw, list) or not raw: + return cls.single(settings.get("workspace") or fallback_root) + + entries: list[WorkspaceEntry] = [] + flagged_defaults: list[str] = [] + for item in raw: + if not isinstance(item, dict): + raise WorkspaceCatalogError("Each workspace catalog entry must be an object.") + entry = WorkspaceEntry( + id=_validated_id(item.get("id")), + name=_validated_name(item.get("name")), + root=_validated_root(item.get("root")), + enabled=_validated_bool(item.get("enabled", True), "enabled"), + default=_validated_bool(item.get("default", False), "default"), + ) + entries.append(entry) + if entry.default: + flagged_defaults.append(entry.id) + + if len(flagged_defaults) > 1: + raise WorkspaceCatalogError("Workspace catalog must contain only one default workspace.") + if explicit_default is not None and not isinstance(explicit_default, str): + raise WorkspaceCatalogError("default_workspace_id must be a string.") + if isinstance(explicit_default, str) and explicit_default and flagged_defaults: + if explicit_default != flagged_defaults[0]: + raise WorkspaceCatalogError("Default workspace flag conflicts with default_workspace_id.") + chosen = ( + explicit_default + if isinstance(explicit_default, str) and explicit_default + else flagged_defaults[0] + if flagged_defaults + else entries[0].id + ) + return cls(entries, chosen) + + def default(self) -> WorkspaceEntry: + return self._by_id[self.default_id] + + def get(self, identifier: str) -> WorkspaceEntry: + entry = self._by_id.get(identifier) + if entry is None or not entry.enabled: + raise WorkspaceCatalogError("Workspace is unknown or disabled.") + return entry + + def enabled_entries(self) -> tuple[WorkspaceEntry, ...]: + return tuple(entry for entry in self.entries if entry.enabled) + + def payload(self) -> dict[str, Any]: + return { + "default_workspace_id": self.default_id, + "workspaces": [entry.payload() for entry in self.enabled_entries()], + } + + def settings_payload(self) -> dict[str, Any]: + return { + "workspace_catalog": [entry.payload() for entry in self.entries], + "default_workspace_id": self.default_id, + } + + @staticmethod + def _validate_roots(entries: list[WorkspaceEntry]) -> None: + roots: list[Path] = [] + identities: set[str] = set() + for entry in entries: + root = entry.root + identity = _root_identity(root) + if identity in identities: + raise WorkspaceCatalogError("Workspace roots must be unique.") + for other in roots: + if _relative_to(root, other) or _relative_to(other, root): + raise WorkspaceCatalogError("Nested workspace roots are not allowed.") + roots.append(root) + identities.add(identity) + + +def _validated_id(raw: Any) -> str: + if not isinstance(raw, str) or WORKSPACE_ID_PATTERN.fullmatch(raw) is None: + raise WorkspaceCatalogError( + "Workspace id must contain 1-128 ASCII letters, digits, dots, underscores, or hyphens." + ) + return raw + + +def _validated_name(raw: Any) -> str: + if not isinstance(raw, str) or not raw.strip() or len(raw.strip()) > 200: + raise WorkspaceCatalogError("Workspace name must contain 1-200 characters.") + return raw.strip() + + +def _validated_bool(raw: Any, field: str) -> bool: + if not isinstance(raw, bool): + raise WorkspaceCatalogError(f"Workspace {field} must be a boolean.") + return raw + + +def _validated_root(raw: Any) -> Path: + if not isinstance(raw, (str, Path)) or not str(raw).strip(): + raise WorkspaceCatalogError("Workspace root is required.") + try: + root = Path(raw).expanduser().resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise WorkspaceCatalogError(f"Workspace root cannot be resolved: {exc}") from exc + if not root.is_dir(): + raise WorkspaceCatalogError("Workspace root must be an existing directory.") + if root == Path(root.anchor): + raise WorkspaceCatalogError("Filesystem roots cannot be managed as workspaces.") + return root + + +def _root_identity(root: Path) -> str: + return os.path.normcase(str(root)) + + +def _relative_to(path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False diff --git a/npm/coding-tools-mcp/test/launcher.test.js b/npm/coding-tools-mcp/test/launcher.test.js index 34b0131..5a917a0 100644 --- a/npm/coding-tools-mcp/test/launcher.test.js +++ b/npm/coding-tools-mcp/test/launcher.test.js @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; -import os from "node:os"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { spawnSync } from "node:child_process"; import test from "node:test"; @@ -8,12 +7,50 @@ import { fileURLToPath } from "node:url"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const launcher = path.join(packageRoot, "bin", "coding-tools-mcp.js"); +const fixtureRoot = path.join(packageRoot, ".tmp"); + +async function createFixtureDirectory(testContext, prefix) { + await mkdir(fixtureRoot, { recursive: true }); + const directory = await mkdtemp(path.join(fixtureRoot, prefix)); + testContext.after(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +async function writeExecutable(directory, name, { captureArgs = false, exitCode = 0 } = {}) { + const windows = process.platform === "win32"; + if (windows) { + const target = path.join(directory, `${name}.exe`); + const preload = path.join(directory, `${name}-stub.cjs`); + await copyFile(process.execPath, target); + await writeFile( + preload, + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const executable = path.basename(process.execPath).toLowerCase();', + 'if (executable === "uvx.exe" || executable === "pipx.exe") {', + captureArgs + ? ' fs.writeFileSync(process.env.RESULT_FILE, `${process.argv.slice(1).join("\\n")}\\n`);' + : "", + ` process.exit(${exitCode});`, + "}", + "", + ].join("\n"), + "utf8", + ); + return { NODE_OPTIONS: `--require=${preload.replaceAll("\\", "/")}` }; + } -async function writeExecutable(directory, name, body) { const target = path.join(directory, name); - await writeFile(target, `#!/bin/sh\n${body}\n`, "utf8"); + const body = [ + "#!/bin/sh", + captureArgs ? 'printf "%s\\n" "$@" > "$RESULT_FILE"' : "", + `exit ${exitCode}`, + "", + ].join("\n"); + await writeFile(target, body, "utf8"); await chmod(target, 0o755); - return target; + return {}; } function runLauncher(binDirectory, args = [], extraEnv = {}, ambientEnv = process.env) { @@ -29,18 +66,27 @@ function runLauncher(binDirectory, args = [], extraEnv = {}, ambientEnv = proces }); } -test("uvx receives the pinned Python package and forwarded arguments", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-uvx-")); +async function readCapturedArgs(output) { + const args = (await readFile(output, "utf8")).trim().split("\n"); + if (process.platform === "win32" && args.length > 0) { + args[0] = path.basename(args[0]); + } + return args; +} + +test("uvx receives the pinned Python package and forwarded arguments", async (t) => { + const directory = await createFixtureDirectory(t, "uvx-"); const output = path.join(directory, "args.txt"); - await writeExecutable(directory, "uvx", 'printf "%s\\n" "$@" > "$RESULT_FILE"'); + const runnerEnv = await writeExecutable(directory, "uvx", { captureArgs: true }); const result = runLauncher(directory, ["--stdio", "--workspace", "/repo"], { + ...runnerEnv, CODING_TOOLS_MCP_VERSION: "0.2.0", RESULT_FILE: output, }); assert.equal(result.status, 0, result.stderr); - assert.deepEqual((await readFile(output, "utf8")).trim().split("\n"), [ + assert.deepEqual(await readCapturedArgs(output), [ "coding-tools-mcp==0.2.0", "--stdio", "--workspace", @@ -48,28 +94,28 @@ test("uvx receives the pinned Python package and forwarded arguments", async () ]); }); -test("pipx is used when uvx is unavailable", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-pipx-")); +test("pipx is used when uvx is unavailable", async (t) => { + const directory = await createFixtureDirectory(t, "pipx-"); const output = path.join(directory, "args.txt"); - await writeExecutable(directory, "pipx", 'printf "%s\\n" "$@" > "$RESULT_FILE"'); + const runnerEnv = await writeExecutable(directory, "pipx", { captureArgs: true }); const result = runLauncher( directory, ["--help"], - { RESULT_FILE: output }, + { ...runnerEnv, RESULT_FILE: output }, { ...process.env, CODING_TOOLS_MCP_VERSION: "9.9.9" }, ); assert.equal(result.status, 0, result.stderr); - assert.deepEqual((await readFile(output, "utf8")).trim().split("\n"), [ + assert.deepEqual(await readCapturedArgs(output), [ "run", "coding-tools-mcp", "--help", ]); }); -test("the launcher explains how to install a supported runner", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-empty-")); +test("the launcher explains how to install a supported runner", async (t) => { + const directory = await createFixtureDirectory(t, "empty-"); const result = runLauncher(directory); assert.equal(result.status, 1); @@ -77,11 +123,11 @@ test("the launcher explains how to install a supported runner", async () => { assert.match(result.stderr, /pip install coding-tools-mcp/); }); -test("the child exit code is preserved", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "coding-tools-mcp-exit-")); - await writeExecutable(directory, "uvx", "exit 7"); +test("the child exit code is preserved", async (t) => { + const directory = await createFixtureDirectory(t, "exit-"); + const runnerEnv = await writeExecutable(directory, "uvx", { exitCode: 7 }); - const result = runLauncher(directory); + const result = runLauncher(directory, [], runnerEnv); assert.equal(result.status, 7); }); diff --git a/pyproject.toml b/pyproject.toml index e333f8c..a649cfa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,4 +47,5 @@ where = [".", "apps/desktop-client"] include = ["coding_tools_mcp*", "mcp_desktop_client*"] [tool.setuptools.package-data] +coding_tools_mcp = ["webui_dist/*"] mcp_desktop_client = ["locales/*.qm", "locales/*.ts"] diff --git a/tests/compliance/test_chat_persistence.py b/tests/compliance/test_chat_persistence.py new file mode 100644 index 0000000..0c61cb6 --- /dev/null +++ b/tests/compliance/test_chat_persistence.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import ctypes +import inspect +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp import chat_cli +from coding_tools_mcp.admin import AdminNotFoundError, AdminService, AdminServiceError, gateway_file_revision +from coding_tools_mcp.codex_sessions import CodexSessionError, CodexSessionScanner, ScanPolicy +from coding_tools_mcp.secret_vault import SecretVault +from coding_tools_mcp.settings_store import ServerSettingsStore +from coding_tools_mcp.transcript import TranscriptStore, WorkspaceScope +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry + + +class ChatPersistenceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.root = Path(self.temp.name) + self.a = self.root / "workspace-a" + self.b = self.root / "workspace-b" + self.a.mkdir() + self.b.mkdir() + self.store = TranscriptStore(self.root / "transcripts.sqlite3") + self.scope_a = WorkspaceScope.create("a", self.a) + self.scope_b = WorkspaceScope.create("b", self.b) + self.scanner = CodexSessionScanner() + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_workspace_identity_partitions_queries_cache_and_stable_deletes(self) -> None: + service_a = self.store.scoped("a", self.a) + service_b = self.store.scoped("b", self.b) + service_a.record_messages( + "same-conversation", + [{"message_id": "same-message", "role": "user", "content": "secret-a"}], + ) + service_b.record_messages( + "same-conversation", + [{"message_id": "same-message", "role": "user", "content": "secret-b"}], + ) + self.assertEqual(service_a.list_conversations()["total"], 1) + self.assertEqual(service_b.list_conversations()["total"], 1) + self.assertEqual( + service_a.conversation_detail("same-conversation")["messages"][0]["content"], + "secret-a", + ) + deleted = self.store.delete_message("a", "same-message") + self.assertEqual(deleted["affected_count"], 1) + self.assertEqual(self.store.delete_message("a", "same-message")["affected_count"], 0) + self.assertEqual( + service_b.conversation_detail("same-conversation")["messages"][0]["content"], + "secret-b", + ) + with self.assertRaises(TypeError): + service_a.list_conversations(workspace_id="b") + + def test_database_reopen_preserves_workspace_partition(self) -> None: + self.store.record_messages( + "a", + "reopen", + [{"message_id": "reopen-message", "role": "user", "content": "persisted"}], + ) + reopened = TranscriptStore(self.root / "transcripts.sqlite3") + self.assertEqual(reopened.list_conversations("a")["total"], 1) + self.assertEqual(reopened.list_conversations("b")["total"], 0) + self.assertEqual( + reopened.conversation_detail("a", "reopen")["messages"][0]["content"], + "persisted", + ) + + def test_summary_pagination_omits_full_content_until_detail(self) -> None: + for index in range(5): + self.store.record_messages( + "a", + f"conversation-{index}", + [{"message_id": f"m-{index}", "role": "user", "content": "X" * 1000}], + title=f"Title {index}", + ) + first = self.store.list_conversations("a", page=1, page_size=2) + second = self.store.list_conversations("a", page=2, page_size=2) + self.assertEqual(first["total"], 5) + self.assertEqual(len(first["items"]), 2) + self.assertEqual(len(second["items"]), 2) + serialized = json.dumps(first) + self.assertNotIn("X" * 500, serialized) + detail = self.store.conversation_detail("a", first["items"][0]["conversation_id"]) + self.assertEqual(len(detail["messages"][0]["content"]), 1000) + + def test_crlf_partial_jsonl_is_a_single_item_error_and_import_continues(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + records = [ + {"type": "session_meta", "payload": {"id": "session-1"}}, + {"type": "user_message", "timestamp": "2026-07-30T01:00:00Z", "message": "private body"}, + ] + path = sessions / "rollout.jsonl" + path.write_bytes( + ("\r\n".join(json.dumps(item) for item in records) + "\r\n{\"type\":").encode("utf-8") + ) + preview = self.scanner.scan(self.scope_a, roots=["sessions"]) + self.assertEqual(preview["candidate_count"], 1) + candidate = preview["candidates"][0] + self.assertEqual(candidate["message_count"], 1) + self.assertEqual(candidate["parse_error_count"], 1) + self.assertNotIn("private body", json.dumps(preview)) + imported = self.scanner.import_candidates( + self.store, + self.scope_a, + roots=["sessions"], + candidate_ids=[candidate["candidate_id"]], + ) + self.assertEqual(imported["inserted_count"], 1) + detail = self.store.conversation_detail("a", candidate["conversation_id"]) + self.assertEqual(detail["messages"][0]["content"], "private body") + + def test_windows_encoding_invalid_encoding_and_size_limits(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + gb = sessions / "gb.jsonl" + gb.write_bytes( + json.dumps( + {"type": "user_message", "message": "中文 Windows 编码"}, + ensure_ascii=False, + ).encode("gb18030") + ) + bad = sessions / "bad.jsonl" + bad.write_bytes(b"\xff\xff\x81") + huge = sessions / "huge.jsonl" + huge.write_bytes(b"x" * 2048) + result = self.scanner.scan( + self.scope_a, + roots=["sessions"], + policy=ScanPolicy(max_file_bytes=1024, max_total_bytes=4096), + ) + by_name = {item["relative_path"].split("/")[-1]: item for item in result["candidates"]} + self.assertEqual(by_name["gb.jsonl"]["encoding"], "gb18030") + self.assertEqual(by_name["gb.jsonl"]["message_count"], 1) + self.assertEqual(by_name["bad.jsonl"]["fatal_error"], "invalid_encoding") + self.assertEqual(by_name["huge.jsonl"]["fatal_error"], "file_too_large") + + def test_scan_rejects_absolute_parent_and_outside_paths_and_skips_symlink_escape(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + outside = self.root / "outside.jsonl" + outside.write_text(json.dumps({"type": "user_message", "message": "outside"}), encoding="utf-8") + with self.assertRaises(CodexSessionError): + self.scanner.scan(self.scope_a, roots=[str(sessions.resolve())]) + with self.assertRaises(CodexSessionError): + self.scanner.scan(self.scope_a, roots=["../"]) + from coding_tools_mcp.codex_sessions import parse_codex_session_file + + with self.assertRaises(CodexSessionError): + parse_codex_session_file(self.scope_a, outside) + link = sessions / "escape.jsonl" + try: + os.symlink(outside, link) + except OSError: + self.skipTest("Host does not permit creating a test symlink.") + result = self.scanner.scan(self.scope_a, roots=["sessions"]) + self.assertEqual(result["candidate_count"], 0) + + @unittest.skipUnless(os.name == "nt", "Windows file-sharing behavior") + def test_locked_windows_file_becomes_item_error_not_list_failure(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + path = sessions / "locked.jsonl" + path.write_text(json.dumps({"type": "user_message", "message": "locked"}), encoding="utf-8") + kernel32 = ctypes.windll.kernel32 + handle = kernel32.CreateFileW( + str(path), + 0x80000000 | 0x40000000, + 0, + None, + 3, + 0x80, + None, + ) + self.assertNotEqual(handle, -1) + try: + result = self.scanner.scan(self.scope_a, roots=["sessions"]) + self.assertEqual(result["candidate_count"], 1) + self.assertIn(result["candidates"][0]["fatal_error"], {"stat_failed", "read_failed"}) + finally: + kernel32.CloseHandle(handle) + + def test_file_count_depth_and_total_read_limits_are_enforced(self) -> None: + sessions = self.a / "sessions" + deep = sessions / "one" / "two" + deep.mkdir(parents=True) + for index in range(4): + (sessions / f"{index}.jsonl").write_text( + json.dumps({"type": "user_message", "message": str(index)}), encoding="utf-8" + ) + (deep / "deep.jsonl").write_text(json.dumps({"type": "user_message", "message": "deep"}), encoding="utf-8") + result = self.scanner.scan( + self.scope_a, + roots=["sessions"], + policy=ScanPolicy(max_depth=0, max_files=2, max_total_bytes=1024, max_file_bytes=1024), + ) + self.assertLessEqual(result["files_considered"], 2) + self.assertTrue(any(item["code"] == "file_limit" for item in result["scan_errors"])) + self.assertNotIn("deep.jsonl", json.dumps(result)) + + def test_imported_message_and_session_ids_are_path_stable_and_do_not_collide(self) -> None: + sessions = self.a / "sessions" + sessions.mkdir() + for name, text in (("one.jsonl", "one"), ("two.jsonl", "two")): + (sessions / name).write_text( + "\n".join( + [ + json.dumps({"type": "session_meta", "payload": {"id": "same-source-session"}}), + json.dumps( + { + "type": "response_item", + "payload": { + "type": "message", + "id": "same-source-message", + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + }, + } + ), + ] + ), + encoding="utf-8", + ) + preview = self.scanner.scan(self.scope_a, roots=["sessions"]) + ids = [item["candidate_id"] for item in preview["candidates"]] + self.assertEqual(len(set(ids)), 2) + imported = self.scanner.import_candidates( + self.store, self.scope_a, roots=["sessions"], candidate_ids=ids + ) + self.assertEqual(imported["inserted_count"], 2) + sessions_payload = self.store.list_imported_sessions("a") + self.assertEqual(sessions_payload["total"], 2) + message_ids: set[str] = set() + for item in preview["candidates"]: + detail = self.store.conversation_detail("a", item["conversation_id"]) + message_ids.add(detail["messages"][0]["message_id"]) + self.assertEqual(len(message_ids), 2) + + def test_scanner_cache_key_contains_workspace_identity(self) -> None: + for scope, root, body in ((self.scope_a, self.a, "A"), (self.scope_b, self.b, "B")): + folder = root / "sessions" + folder.mkdir() + (folder / "same.jsonl").write_text( + json.dumps({"type": "user_message", "message": body}), encoding="utf-8" + ) + self.scanner.scan(scope, roots=["sessions"]) + keys = list(self.scanner._cache) # contract-level cache-key inspection + self.assertEqual({key[0] for key in keys}, {"a", "b"}) + + def test_no_chat_or_transcript_content_is_sent_to_telemetry(self) -> None: + import coding_tools_mcp.codex_sessions as codex_sessions + import coding_tools_mcp.transcript as transcript + + sources = inspect.getsource(transcript) + inspect.getsource(codex_sessions) + self.assertNotIn("telemetry", sources.lower()) + self.assertNotIn("SessionTelemetry", sources) + + +class ChatAdminServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.root = Path(self.temp.name) + self.a = self.root / "a" + self.b = self.root / "b" + self.a.mkdir() + self.b.mkdir() + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("a", "A", self.a, True, True), + WorkspaceEntry("b", "B", self.b, True, False), + ], + "a", + ) + settings = {**catalog.settings_payload(), "workspace": str(self.a)} + self.settings_store = ServerSettingsStore(self.root / "settings.json") + self.settings_store.write(settings) + self.transcripts = TranscriptStore(self.root / "transcripts.sqlite3") + gateway = self.root / "gateway.json" + self.service = AdminService( + settings_store=self.settings_store, + active_settings=settings, + fallback_workspace=self.a, + gateway_path=gateway, + active_gateway_revision=gateway_file_revision(gateway), + secret_vault=SecretVault(self.root / "vault.json", "master-key"), + transcript_store=self.transcripts, + session_scanner=CodexSessionScanner(), + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_admin_summary_detail_and_idempotent_workspace_keyed_delete(self) -> None: + self.service.dispatch( + "POST", + "/admin/api/chat/conversations/a/c/messages", + {"messages": [{"message_id": "same", "role": "user", "content": "body-a"}]}, + {}, + ) + self.service.dispatch( + "POST", + "/admin/api/chat/conversations/b/c/messages", + {"messages": [{"message_id": "same", "role": "user", "content": "body-b"}]}, + {}, + ) + summary = self.service.dispatch("GET", "/admin/api/chat/conversations", {}, {"page_size": "1"}) + self.assertEqual(summary["total"], 2) + self.assertNotIn("body-a", json.dumps(summary)) + detail = self.service.dispatch("GET", "/admin/api/chat/conversations/a/c", {}, {}) + self.assertEqual(detail["messages"][0]["content"], "body-a") + deleted = self.service.dispatch("DELETE", "/admin/api/chat/messages/a/same", {}, {}) + self.assertEqual(deleted["affected_count"], 1) + repeated = self.service.dispatch("DELETE", "/admin/api/chat/messages/a/same", {}, {}) + self.assertEqual(repeated["affected_count"], 0) + other = self.service.dispatch("GET", "/admin/api/chat/conversations/b/c", {}, {}) + self.assertEqual(other["messages"][0]["content"], "body-b") + + def test_admin_scan_rejects_unknown_workspace_and_path_escape(self) -> None: + with self.assertRaises(AdminNotFoundError): + self.service.codex_scan({"workspace_id": "missing", "roots": ["."]}) + with self.assertRaises(AdminServiceError): + self.service.codex_scan({"workspace_id": "a", "roots": ["../"]}) + + def test_admin_import_and_session_delete_use_workspace_and_stable_id(self) -> None: + folder = self.a / "sessions" + folder.mkdir() + (folder / "one.jsonl").write_text( + json.dumps({"type": "user_message", "message": "imported body"}), encoding="utf-8" + ) + preview = self.service.codex_scan({"workspace_id": "a", "roots": ["sessions"]}) + candidate = preview["candidates"][0] + imported = self.service.codex_import( + {"workspace_id": "a", "roots": ["sessions"], "candidate_ids": [candidate["candidate_id"]]} + ) + self.assertEqual(imported["inserted_count"], 1) + sessions = self.service.codex_sessions({"workspace_id": "a"}) + self.assertEqual(sessions["total"], 1) + deleted = self.service.dispatch( + "DELETE", + f"/admin/api/codex/sessions/a/{candidate['session_id']}", + {}, + {}, + ) + self.assertEqual(deleted["deleted_session_count"], 1) + self.assertEqual(deleted["deleted_conversation_count"], 1) + self.assertEqual(deleted["deleted_message_count"], 1) + self.assertEqual(deleted["affected_count"], 3) + repeated = self.service.dispatch( + "DELETE", + f"/admin/api/codex/sessions/a/{candidate['session_id']}", + {}, + {}, + ) + self.assertEqual(repeated["affected_count"], 0) + + +class ChatCliTests(unittest.TestCase): + def test_cli_reads_gb18030_bytes_and_remains_workspace_scoped(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + workspace = root / "workspace" + workspace.mkdir() + db = root / "chat.sqlite3" + payload = { + "conversation_id": "conversation-1", + "message_id": "message-1", + "role": "assistant", + "content": "中文 CLI 内容\r\n第二行", + } + + class BytesStdin: + def __init__(self, raw: bytes) -> None: + self.buffer = io.BytesIO(raw) + + output = io.StringIO() + with patch.object(sys, "stdin", BytesStdin(json.dumps(payload, ensure_ascii=False).encode("gb18030"))), redirect_stdout(output): + code = chat_cli.main( + [ + "--db-path", str(db), + "--workspace-id", "workspace-a", + "--workspace-root", str(workspace), + "record-message", "--stdin-json", + ] + ) + self.assertEqual(code, 0) + recorded = json.loads(output.getvalue()) + self.assertEqual(recorded["inserted_count"], 1) + detail = TranscriptStore(db).conversation_detail("workspace-a", "conversation-1") + self.assertIn("第二行", detail["messages"][0]["content"]) + self.assertIsNone(TranscriptStore(db).conversation_detail("workspace-b", "conversation-1")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compliance/test_docs_required.py b/tests/compliance/test_docs_required.py index 925cd63..70d4f13 100644 --- a/tests/compliance/test_docs_required.py +++ b/tests/compliance/test_docs_required.py @@ -30,6 +30,11 @@ def test_required_operator_docs_exist(self) -> None: "docs/troubleshooting.md", "docs/competitive-analysis.md", "docs/runtime-contract-v0.2.md", + "docs/remote-mcp.md", + "docs/admin-api.md", + "docs/admin-webui.md", + "docs/chat-persistence.md", + "docs/migration-v0.1-to-v0.2.2.md", "Dockerfile", ".dockerignore", "docker-compose.yml", @@ -79,6 +84,70 @@ def test_docs_contain_required_operational_topics(self) -> None: with self.subTest(path=rel_path, needle=needle): self.assertIn(needle, text) + + def test_integrated_v022_docs_cover_final_boundaries(self) -> None: + expectations = { + "README.md": [ + "Integrated v0.2.2 architecture", + "refresh_token", + "Admin WebUI", + "migration-v0.1-to-v0.2.2.md", + ], + "README.zh-CN.md": [ + "集成后的 v0.2.2 架构", + "持久化 OAuth", + "升级与回滚", + ], + "docs/runtime-contract-v0.2.md": [ + "Session identity and Workspace binding", + "listChanged: false", + "UPSTREAM_TOOL_COLLISION", + "refresh_token", + "legacy_tool_profile_ignored", + ], + "docs/remote-mcp.md": [ + "CODING_TOOLS_MCP_SECRETS_KEY", + "oauth.sqlite3", + "oauth_client_workspace_bindings", + "restart_required", + ], + "docs/admin-webui.md": [ + "stale HTTP 409", + "no upstream start, stop, reload", + "textContent", + ], + "docs/chat-persistence.md": [ + "workspace_id", + "summary", + "telemetry", + ], + "docs/migration-v0.1-to-v0.2.2.md": [ + "legacy_tool_profile_ignored", + "server-settings.json", + "oauth.sqlite3", + "oauth-secrets.json", + "full snapshot rollback", + "Refresh rotation", + "original refresh token remains retryable", + ], + "docs/telemetry.md": [ + "chat/transcript content", + "Workspace IDs, OAuth Agent/Client", + "CODING_TOOLS_MCP_TELEMETRY=off", + ], + } + for rel_path, needles in expectations.items(): + text = (ROOT / rel_path).read_text(encoding="utf-8") + for needle in needles: + with self.subTest(path=rel_path, needle=needle): + self.assertIn(needle, text) + + def test_v01_profile_doc_is_history_not_current_contract(self) -> None: + self.assertFalse((ROOT / "docs/profile-v0.1.md").exists()) + contract = (ROOT / "docs/runtime-contract-v0.2.md").read_text(encoding="utf-8") + self.assertIn("There are no\ntool profiles", contract) + self.assertNotIn("--tool-profile", contract) + def test_ci_workflows_include_required_gates(self) -> None: compliance = (ROOT / ".github/workflows/compliance.yml").read_text(encoding="utf-8") for needle in ( diff --git a/tests/compliance/test_mcp_admin.py b/tests/compliance/test_mcp_admin.py new file mode 100644 index 0000000..4e717de --- /dev/null +++ b/tests/compliance/test_mcp_admin.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import hashlib +import inspect +import json +import threading +import time +import unittest +import urllib.error +import urllib.request +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp.admin import ( + AdminConflictError, + AdminService, + AdminServiceError, + AdminUnavailableError, + document_revision, + gateway_file_revision, +) +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore +from coding_tools_mcp.secret_vault import SecretVault +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + configure_allowed_origins, + is_allowed_origin, + upstream_secret_resolver, +) +from coding_tools_mcp.settings_store import ServerSettingsStore +from coding_tools_mcp.transcript import TranscriptStore +from coding_tools_mcp.upstream import UpstreamConfigSnapshot, UpstreamServerConfig +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry + + +class AdminServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = TemporaryDirectory() + self.root = Path(self.temp.name) + self.workspace_a = self.root / "workspace-a" + self.workspace_b = self.root / "workspace-b" + self.workspace_a.mkdir() + self.workspace_b.mkdir() + self.settings_path = self.root / "server-settings.json" + self.gateway_path = self.root / "mcp-servers.json" + self.vault = SecretVault(self.root / "server-secrets.json", "admin-test-master-key") + self.store = ServerSettingsStore(self.settings_path) + catalog = WorkspaceCatalog( + [WorkspaceEntry("a", "A", self.workspace_a, enabled=True, default=True)], + "a", + ) + initial = { + **catalog.settings_payload(), + "workspace": str(self.workspace_a), + "host": "127.0.0.1", + "port": 8000, + "permission_mode": "safe", + "shell_env_inherit": "core", + "allowed_origins": ["https://admin.example"], + "admin_token_secret_ref": "admin/token", + } + self.store.write(initial) + self.active = dict(initial) + self.active["port"] = 7000 + self.oauth = OAuthAuthorizationStore( + self.root / "oauth.sqlite3", + pepper=b"admin-pepper" * 4, + ) + self.oauth.upsert_client( + "agent-a", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + token_endpoint_auth_method="client_secret_post", + client_secret_digest=hashlib.sha256(b"client-secret-canary").hexdigest(), + workspace_id="a", + ) + self.oauth.register_signing_key( + "kid-a", + "fingerprint-a", + secret_ref="oauth/signing/kid-a", + ) + self.grant_id = self.oauth.create_grant("agent-a", "mcp") + self.oauth.record_access_token( + "jti-a", + self.grant_id, + "agent-a", + "kid-a", + "mcp", + issued_at=time.time(), + expires_at=time.time() + 3600, + ) + self.family_id, _refresh = self.oauth.issue_refresh_token( + self.grant_id, + "agent-a", + "mcp", + expires_at=time.time() + 7200, + ) + self.active_gateway_status = { + "enabled": True, + "tool_count": 1, + "servers": [{"alias": "active", "initialized": True}], + } + self.service = AdminService( + settings_store=self.store, + active_settings=self.active, + fallback_workspace=self.workspace_a, + gateway_path=self.gateway_path, + active_gateway_revision=gateway_file_revision(self.gateway_path), + secret_vault=self.vault, + oauth_store=self.oauth, + active_gateway_status=lambda: self.active_gateway_status, + ) + + def tearDown(self) -> None: + self.temp.cleanup() + + def test_status_reports_only_privacy_safe_telemetry_mode_and_docs(self) -> None: + with patch("coding_tools_mcp.admin.telemetry_mode", return_value="debug") as mode: + payload = self.service.status_payload() + + mode.assert_called_once_with() + self.assertEqual( + payload["telemetry"], + {"mode": "debug", "docs": "docs/telemetry.md"}, + ) + serialized = json.dumps(payload["telemetry"], sort_keys=True) + for forbidden in ( + "workspace_id", + "agent_id", + "client_id", + "command", + "arguments", + "file_content", + "path", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, serialized) + + def test_settings_separate_active_persisted_pending_and_reject_stale_revision(self) -> None: + payload = self.service.settings_payload() + self.assertEqual(payload["active"]["port"], 7000) + self.assertEqual(payload["persisted"]["port"], 8000) + self.assertIn("port", payload["pending_restart"]) + self.assertEqual(payload["persisted"]["admin_token_secret_ref"], {"configured": True}) + revision = payload["persisted_revision"] + + saved = self.service.save_settings( + { + "expected_revision": revision, + "updates": {"port": 9000, "allowed_origins": ["https://ops.example/"]}, + } + ) + self.assertEqual(saved["persisted"]["port"], 9000) + self.assertEqual(saved["persisted"]["allowed_origins"], ["https://ops.example"]) + with self.assertRaises(AdminConflictError): + self.service.save_settings( + {"expected_revision": revision, "updates": {"port": 9001}} + ) + + def test_settings_and_http_cors_use_the_same_origin_validator(self) -> None: + validated = self.service.validate_settings( + {"updates": {"allowed_origins": ["HTTPS://Example.COM:443/"]}} + ) + origins = validated["normalized"]["allowed_origins"] + configure_allowed_origins(origins) + self.assertTrue(is_allowed_origin("https://example.com:443")) + self.assertFalse(is_allowed_origin("https://example.com/path")) + with self.assertRaisesRegex(ValueError, "Unsupported allowed origin"): + self.service.validate_settings({"updates": {"allowed_origins": ["*"]}}) + + def test_gateway_save_is_redacted_restart_only_and_never_hot_reloads(self) -> None: + self.vault.set_secret("github/token", "upstream-secret-canary") + payload = self.service.gateway_payload() + saved = self.service.save_gateway( + { + "expected_revision": payload["persisted_revision"], + "document": { + "servers": { + "github": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "example-mcp"], + "enabled": True, + "env": {"GITHUB_TOKEN": {"secret_ref": "github/token"}}, + } + } + }, + } + ) + self.assertTrue(saved["restart_required"]) + self.assertFalse(saved["dynamic_reload"]) + self.assertEqual(saved["active_status"], self.active_gateway_status) + self.assertNotIn("github/token", json.dumps(saved)) + self.assertNotIn("upstream-secret-canary", self.gateway_path.read_text(encoding="utf-8")) + self.assertEqual(self.active_gateway_status["tool_count"], 1) + with self.assertRaisesRegex(AdminServiceError, "Sensitive Gateway headers"): + self.service.save_gateway( + { + "expected_revision": saved["persisted_revision"], + "document": { + "servers": { + "remote": { + "transport": "streamable_http", + "url": "http://127.0.0.1:9000/mcp", + "headers": {"X-API-Key": "plaintext-canary"}, + } + } + }, + } + ) + + def test_gateway_secret_resolver_is_vault_backed_and_fails_closed(self) -> None: + snapshot = UpstreamConfigSnapshot( + configs=( + UpstreamServerConfig( + alias="remote", + transport="stdio", + command="uvx", + args=("remote-mcp",), + env={"TOKEN": {"secret_ref": "remote/token"}}, + ), + ) + ) + self.vault.set_secret("remote/token", "resolved-secret-canary") + resolver = upstream_secret_resolver(snapshot, self.vault) + self.assertIsNotNone(resolver) + assert resolver is not None + self.assertEqual(resolver("remote/token"), "resolved-secret-canary") + with self.assertRaisesRegex(ValueError, "Gateway secret_ref requires"): + upstream_secret_resolver( + snapshot, + SecretVault(self.root / "no-key.json", None), + ) + + def test_gateway_secret_ref_fails_closed_without_vault(self) -> None: + service = AdminService( + settings_store=self.store, + active_settings=self.active, + fallback_workspace=self.workspace_a, + gateway_path=self.gateway_path, + active_gateway_revision=gateway_file_revision(self.gateway_path), + secret_vault=SecretVault(self.root / "disabled-vault.json", None), + ) + with self.assertRaises(AdminUnavailableError): + service.secrets_payload() + with self.assertRaises(AdminUnavailableError): + service.save_gateway( + { + "expected_revision": service.gateway_payload()["persisted_revision"], + "document": { + "servers": { + "remote": { + "transport": "stdio", + "command": "uvx", + "args": ["remote-mcp"], + "env": {"TOKEN": {"secret_ref": "remote/token"}}, + } + } + }, + } + ) + + def test_oauth_lists_are_redacted_and_actions_are_idempotent(self) -> None: + clients = self.service.oauth_payload("clients", {}) + encoded = json.dumps(clients) + self.assertNotIn("client-secret-canary", encoded) + self.assertNotIn("client_secret_digest", encoded) + keys = self.service.oauth_payload("signing-keys", {}) + self.assertNotIn("secret_ref", json.dumps(keys)) + + first = self.service.oauth_action("tokens", "jti-a", "revoke") + second = self.service.oauth_action("tokens", "jti-a", "revoke") + self.assertEqual(first["affected_count"], 1) + self.assertIsNotNone(first["audit_event_id"]) + self.assertEqual(second["affected_count"], 0) + self.assertIsNone(second["audit_event_id"]) + + family = self.service.oauth_action("refresh-families", self.family_id, "revoke") + grant = self.service.oauth_action("grants", self.grant_id, "revoke") + client = self.service.oauth_action("clients", "agent-a", "disable") + key = self.service.oauth_action("signing-keys", "kid-a", "revoke") + self.assertEqual( + [family["affected_count"], grant["affected_count"], client["affected_count"], key["affected_count"]], + [1, 1, 1, 1], + ) + cannot_reactivate = self.service.oauth_action( + "signing-keys", "kid-a", "activate" + ) + self.assertEqual(cannot_reactivate["affected_count"], 0) + self.assertIsNone(cannot_reactivate["audit_event_id"]) + + def test_workspace_actions_reuse_catalog_validation_and_check_only_known_ids(self) -> None: + payload = self.service.workspaces_payload() + added = self.service.workspace_add( + { + "expected_revision": payload["persisted_revision"], + "workspace": { + "id": "b", + "name": "B", + "root": str(self.workspace_b), + "enabled": True, + }, + } + ) + made_default = self.service.workspace_default( + "b", {"expected_revision": added["persisted_revision"]} + ) + self.assertEqual(made_default["default_workspace_id"], "b") + checked = self.service.workspace_check("b") + self.assertTrue(checked["check"]["is_directory"]) + disabled = self.service.workspace_disable( + "a", {"expected_revision": made_default["persisted_revision"]} + ) + self.assertFalse(next(item for item in disabled["workspace_catalog"] if item["id"] == "a")["enabled"]) + with self.assertRaisesRegex(ValueError, "not present"): + self.service.workspace_check(str(self.root / "unmanaged")) + + def test_secret_api_never_returns_secret_values(self) -> None: + result = self.service.set_secret("service/key", {"value": "vault-value-canary"}) + self.assertNotIn("vault-value-canary", json.dumps(result)) + self.assertTrue(result["created"]) + overwritten = self.service.set_secret( + "service/key", {"value": "replacement-vault-canary"} + ) + self.assertFalse(overwritten["created"]) + self.assertEqual(overwritten["affected_count"], 1) + self.assertNotIn("replacement-vault-canary", json.dumps(overwritten)) + listed = self.service.secrets_payload() + self.assertIn({"name": "service/key", "configured": True}, listed["secrets"]) + self.assertNotIn("vault-value-canary", json.dumps(listed)) + + def test_handler_source_contains_no_sql_or_gateway_reload(self) -> None: + source = inspect.getsource(MCPHandler) + self.assertNotRegex(source, r"\b(?:SELECT|INSERT|UPDATE|DELETE FROM|PRAGMA)\b") + admin_source = inspect.getsource(AdminService) + self.assertNotIn("reload_upstream", admin_source) + self.assertNotIn("start_server", admin_source) + self.assertNotIn("stop_server", admin_source) + + +class AdminHTTPAuthenticationTests(unittest.TestCase): + def test_ordinary_mcp_bearer_is_not_admin_authentication(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + settings = ServerSettingsStore(root / "settings.json") + workspace = root / "workspace" + workspace.mkdir() + settings.write({"workspace": str(workspace)}) + service = AdminService( + settings_store=settings, + active_settings={"workspace": str(workspace)}, + fallback_workspace=workspace, + gateway_path=root / "gateway.json", + active_gateway_revision=document_revision({"servers": {}}), + secret_vault=SecretVault(root / "vault.json", "key"), + transcript_store=TranscriptStore(root / "transcripts.sqlite3"), + ) + runtime = Runtime(workspace, auth_token="ordinary-mcp-token", transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda _context: Runtime(workspace, transport="http"), + admin_service=service, + admin_token="dedicated-admin-token", + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_address[1]}/admin/api/status" + try: + with self.assertRaises(urllib.error.HTTPError) as denied: + urllib.request.urlopen( + urllib.request.Request( + url, + headers={"Authorization": "Bearer ordinary-mcp-token"}, + ), + timeout=5, + ) + self.assertEqual(denied.exception.code, 401) + with self.assertRaises(urllib.error.HTTPError) as page_denied: + urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin", + headers={"Authorization": "Bearer ordinary-mcp-token"}, + ), + timeout=5, + ) + self.assertEqual(page_denied.exception.code, 401) + before = service.settings_payload()["persisted_revision"] + with self.assertRaises(urllib.error.HTTPError) as write_denied: + urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin/api/settings", + data=json.dumps( + { + "expected_revision": before, + "updates": {"port": 9999}, + } + ).encode("utf-8"), + headers={ + "Authorization": "Bearer ordinary-mcp-token", + "Content-Type": "application/json", + }, + method="PUT", + ), + timeout=5, + ) + self.assertEqual(write_denied.exception.code, 401) + self.assertEqual(service.settings_payload()["persisted_revision"], before) + with self.assertRaises(urllib.error.HTTPError) as delete_denied: + urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin/api/chat/messages/ws-any/message-any", + headers={"Authorization": "Bearer ordinary-mcp-token"}, + method="DELETE", + ), + timeout=5, + ) + self.assertEqual(delete_denied.exception.code, 401) + with urllib.request.urlopen( + urllib.request.Request( + url, + headers={"Authorization": "Bearer dedicated-admin-token"}, + ), + timeout=5, + ) as response: + payload = json.loads(response.read()) + self.assertTrue(payload["ok"]) + with urllib.request.urlopen( + urllib.request.Request( + f"http://127.0.0.1:{server.server_address[1]}/admin", + headers={"Authorization": "Bearer dedicated-admin-token"}, + ), + timeout=5, + ) as response: + page = response.read().decode("utf-8") + self.assertIn('data-build-source="admin.js"', page) + self.assertNotIn('src="./admin.js"', page) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compliance/test_mcp_contract.py b/tests/compliance/test_mcp_contract.py index 6179443..26cf7be 100644 --- a/tests/compliance/test_mcp_contract.py +++ b/tests/compliance/test_mcp_contract.py @@ -7,8 +7,10 @@ import os import select import signal +import shutil import subprocess import sys +import tempfile import time import urllib.error import urllib.parse @@ -414,7 +416,10 @@ def test_oauth_public_client_pkce_flow_succeeds(self) -> None: try: metadata = self.wait_for_json(f"{base_url}/.well-known/oauth-authorization-server") self.assertEqual(metadata.get("issuer"), base_url) - self.assertEqual(metadata.get("grant_types_supported"), ["authorization_code"]) + self.assertEqual( + metadata.get("grant_types_supported"), + ["authorization_code", "refresh_token"], + ) self.assertEqual(metadata.get("response_types_supported"), ["code"]) self.assertEqual( set(metadata.get("token_endpoint_auth_methods_supported", [])), @@ -698,7 +703,10 @@ def test_oauth_dynamic_registration_normalizes_unsupported_flow_metadata(self) - ) self.assertEqual(status, 201, response_body) response = json.loads(response_body) - self.assertEqual(response.get("grant_types"), ["authorization_code"]) + self.assertEqual( + response.get("grant_types"), + ["authorization_code", "refresh_token"], + ) self.assertEqual(response.get("response_types"), ["code"]) refresh_body = urllib.parse.urlencode( @@ -717,7 +725,7 @@ def test_oauth_dynamic_registration_normalizes_unsupported_flow_metadata(self) - headers={"Content-Type": "application/x-www-form-urlencoded"}, ) self.assertEqual(refresh_status, 400) - self.assertEqual(json.loads(refresh_response).get("error"), "unsupported_grant_type") + self.assertEqual(json.loads(refresh_response).get("error"), "invalid_grant") unsupported_only_body = json.dumps( { @@ -732,8 +740,11 @@ def test_oauth_dynamic_registration_normalizes_unsupported_flow_metadata(self) - body=unsupported_only_body, headers={"Content-Type": "application/json"}, ) - self.assertEqual(unsupported_status, 400) - self.assertEqual(json.loads(unsupported_response).get("error"), "invalid_client_metadata") + self.assertEqual(unsupported_status, 201) + self.assertEqual( + json.loads(unsupported_response).get("grant_types"), + ["refresh_token"], + ) unsupported_response_type_body = json.dumps( { @@ -1144,6 +1155,10 @@ def raw_post_to_auth_server( def oauth_server_env(self, **overrides: str) -> dict[str, str]: env = self.server_process_env() + config_dir = tempfile.mkdtemp(prefix="coding-tools-mcp-oauth-test-") + self.addCleanup(shutil.rmtree, config_dir, True) + env["CODING_TOOLS_MCP_CONFIG_DIR"] = config_dir + env["CODING_TOOLS_MCP_SECRETS_KEY"] = "synthetic-compliance-master-key" for name in ( "CODING_TOOLS_MCP_OAUTH_CLIENT_ID", "CODING_TOOLS_MCP_OAUTH_CLIENT_SECRET", diff --git a/tests/compliance/test_oauth_persistence.py b/tests/compliance/test_oauth_persistence.py new file mode 100644 index 0000000..b38b5f9 --- /dev/null +++ b/tests/compliance/test_oauth_persistence.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +import shutil +import sqlite3 +import tempfile +import time +import unittest +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator + +from coding_tools_mcp.oauth import ( + create_access_token, + create_authorization_grant, + exchange_refresh_token, + issue_refresh_token, + revoke_signing_key, + rotate_signing_key, + validate_access_token, +) +from coding_tools_mcp.server import build_persistent_oauth_config + + +ISSUER = "https://mcp.example" + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class OAuthPersistenceComplianceTests(unittest.TestCase): + def test_restart_refresh_rotation_and_key_revocation_fail_closed(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-compliance-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="compliance-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="compliance-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + old_kid = str(config.signing_kid) + old_secret = config.signing_keys[old_kid] + old_access = create_access_token( + config, + ISSUER, + client_id="compliance-agent", + grant_id=grant_id, + ) + refresh_token = issue_refresh_token( + config, + grant_id=grant_id, + client_id="compliance-agent", + scopes="mcp", + ) + rotated = rotate_signing_key(config) + new_kid = str(rotated.signing_kid) + new_secret = rotated.signing_keys[new_kid] + + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-compliance-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="compliance-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + self.assertEqual(reopened.signing_kid, new_kid) + self.assertTrue(validate_access_token(old_access, reopened, ISSUER)) + exchanged = exchange_refresh_token( + reopened, + refresh_token=refresh_token, + client_id="compliance-agent", + client_secret="", + auth_method="none", + server_url=ISSUER, + ) + self.assertTrue( + validate_access_token( + str(exchanged["access_token"]), + reopened, + ISSUER, + ) + ) + self.assertNotEqual(exchanged["refresh_token"], refresh_token) + + self.assertTrue(revoke_signing_key(reopened, old_kid)) + self.assertFalse(validate_access_token(old_access, reopened, ISSUER)) + self.assertTrue( + validate_access_token( + str(exchanged["access_token"]), + reopened, + ISSUER, + ) + ) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + database_bytes = (root / "oauth.sqlite3").read_bytes() + self.assertNotIn(refresh_token.encode("utf-8"), database_bytes) + self.assertNotIn(old_secret, database_bytes) + self.assertNotIn(new_secret, database_bytes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/compliance/test_runtime_helpers.py b/tests/compliance/test_runtime_helpers.py index c6ead07..25bed11 100644 --- a/tests/compliance/test_runtime_helpers.py +++ b/tests/compliance/test_runtime_helpers.py @@ -3,6 +3,7 @@ import builtins import os import signal +import shlex import shutil import subprocess import sys @@ -40,6 +41,22 @@ from tests.compliance.fixtures import git_fixture_preflight_error, init_git +def python_shell_command(source: str) -> str: + argv = [sys.executable, "-c", source] + return subprocess.list2cmdline(argv) if os.name == "nt" else shlex.join(argv) + + +def windows_shell_env() -> dict[str, str]: + if os.name != "nt": + return {} + return { + "COMSPEC": os.environ.get("COMSPEC", r"C:\Windows\System32\cmd.exe"), + "SYSTEMROOT": os.environ.get("SYSTEMROOT", r"C:\Windows"), + "WINDIR": os.environ.get("WINDIR", r"C:\Windows"), + "PATHEXT": os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD"), + } + + @contextmanager def fake_landlock_exec() -> Iterator[dict[str, object]]: """Patch landlock + Popen so exec_command runs without spawning a process. @@ -127,6 +144,11 @@ def fake_hasattr(value: object, name: str) -> bool: patch.object(processes_module.os, "name", "nt"), patch.object(processes_module, "hasattr", side_effect=fake_hasattr, create=True), patch.object(processes_module.signal, "CTRL_BREAK_EVENT", 999, create=True), + patch.object( + processes_module.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0), + ) as taskkill, ): graceful = FakeProcess() processes_module.terminate_process_group( # type: ignore[arg-type] @@ -141,7 +163,14 @@ def fake_hasattr(value: object, name: str) -> bool: ) self.assertEqual(graceful.calls, [("send_signal", 999), ("wait", 1)]) - self.assertEqual(forced.calls, ["kill", ("wait", 1)]) + self.assertEqual(forced.calls, [("wait", 2)]) + taskkill.assert_called_once_with( + ["taskkill", "/PID", "123", "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=5, + check=False, + ) def test_atomic_patch_commit_rolls_back_all_files_after_mid_commit_failure(self) -> None: with TemporaryDirectory() as tmp: @@ -406,8 +435,7 @@ def test_allow_network_only_opens_network_gate(self) -> None: def test_command_env_core_is_not_windows_toolchain_specific(self) -> None: with TemporaryDirectory() as tmp: - workspace = Path(tmp) - runtime = Runtime(workspace) + runtime = Runtime(Path(tmp)) host_env = { "Path": r"C:\VS\VC\Tools\MSVC\bin;C:\Windows\System32", "PATHEXT": ".COM;.EXE;.BAT;.CMD", @@ -416,8 +444,8 @@ def test_command_env_core_is_not_windows_toolchain_specific(self) -> None: "INCLUDE": r"C:\VS\VC\Tools\MSVC\include;C:\SDK\Include", "LIB": r"C:\VS\VC\Tools\MSVC\lib;C:\SDK\Lib", "LIBPATH": r"C:\VS\VC\Tools\MSVC\libpath", - "WindowsSdkDir": r"C:\Program Files (x86)\Windows Kits\10\\", - "VCToolsInstallDir": r"C:\VS\VC\Tools\MSVC\14.99.99999\\", + "WindowsSdkDir": r"C:\Program Files (x86)\Windows Kits\10", + "VCToolsInstallDir": r"C:\VS\VC\Tools\MSVC\14.99.99999", "VSCMD_ARG_TGT_ARCH": "x64", "UNRELATED": "drop-me", "VSCMD_SECRET": "drop-me-too", @@ -426,29 +454,29 @@ def test_command_env_core_is_not_windows_toolchain_specific(self) -> None: patch.object(server_module.os, "name", "nt"), patch.dict(server_module.os.environ, host_env, clear=True), ): - env = runtime._command_env({"CUSTOM": "ok", "OPENAI_API_KEY": "sk-test-secret-value"}) + env = runtime._command_env( + {"CUSTOM": "ok", "OPENAI_API_KEY": "sk-test-secret-value"} + ) - self.assertEqual(env.get("Path"), host_env["Path"]) - self.assertEqual(env.get("PATHEXT"), host_env["PATHEXT"]) - self.assertEqual(env.get("SystemRoot"), host_env["SystemRoot"]) - self.assertEqual(env.get("ComSpec"), host_env["ComSpec"]) + normalized_env = {key.upper(): value for key, value in env.items()} + self.assertEqual(normalized_env.get("PATH"), host_env["Path"]) + self.assertEqual(normalized_env.get("PATHEXT"), host_env["PATHEXT"]) + self.assertEqual(normalized_env.get("SYSTEMROOT"), host_env["SystemRoot"]) + self.assertEqual(normalized_env.get("COMSPEC"), host_env["ComSpec"]) self.assertEqual(env.get("CUSTOM"), "ok") self.assertEqual(env.get("HOME"), str(runtime.command_home_dir())) self.assertEqual(env.get("TEMP"), str(runtime.command_tmp_dir())) self.assertEqual(env.get("TMP"), str(runtime.command_tmp_dir())) - self.assertNotIn("INCLUDE", env) - self.assertNotIn("LIB", env) - self.assertNotIn("LIBPATH", env) - self.assertNotIn("WindowsSdkDir", env) - self.assertNotIn("VCToolsInstallDir", env) - self.assertNotIn("VSCMD_ARG_TGT_ARCH", env) - self.assertNotIn("UNRELATED", env) - self.assertNotIn("VSCMD_SECRET", env) - self.assertNotIn("OPENAI_API_KEY", env) + for name in ( + "INCLUDE", "LIB", "LIBPATH", "WindowsSdkDir", + "VCToolsInstallDir", "VSCMD_ARG_TGT_ARCH", "UNRELATED", + "VSCMD_SECRET", "OPENAI_API_KEY", + ): + self.assertNotIn(name, env) self.assertTrue(runtime.command_home_dir().is_dir()) self.assertTrue(runtime.command_tmp_dir().is_dir()) self.assertTrue(runtime.cache_dir.is_dir()) - self.assertFalse((workspace / ".coding-tools").exists()) + runtime.close() def test_command_env_uses_external_home_tmp_and_cache_without_ecosystem_cache_vars(self) -> None: with TemporaryDirectory() as tmp: @@ -656,21 +684,38 @@ def test_command_policy_unwraps_env_before_path_checks(self) -> None: def test_exec_command_warns_and_runs_when_landlock_is_unavailable(self) -> None: with TemporaryDirectory() as tmp: - runtime = Runtime(Path(tmp)) + runtime = Runtime(Path(tmp), permission_mode="trusted") original = server_module.open_landlock_ruleset - def unavailable(_workspace: Path, _read_roots: list[str], **_kwargs: object) -> int: - raise ToolFailure("SANDBOX_UNAVAILABLE", "test landlock unavailable", category="security") + def unavailable( + _workspace: Path, _read_roots: list[str], **_kwargs: object + ) -> int: + raise ToolFailure( + "SANDBOX_UNAVAILABLE", + "test landlock unavailable", + category="security", + ) server_module.open_landlock_ruleset = unavailable try: - result = runtime.exec_command({"cmd": "printf ok", "timeout_ms": 5000, "yield_time_ms": 1000}) + result = runtime.exec_command( + { + "cmd": python_shell_command( + "import sys; sys.stdout.write('ok')" + ), + "timeout_ms": 5000, + "yield_time_ms": 1000, + } + ) finally: server_module.open_landlock_ruleset = original + runtime.close() self.assertTrue(result["ok"]) self.assertEqual(result["stdout"], "ok") - self.assertTrue(any("Landlock" in warning for warning in result.get("warnings", []))) + self.assertTrue( + any("Landlock" in warning for warning in result.get("warnings", [])) + ) def test_exec_command_uses_landlock_wrapper_without_preexec_fn(self) -> None: with TemporaryDirectory() as tmp: @@ -754,12 +799,13 @@ def test_guard_allow_roots_include_dns_toolchain_path_and_java_home(self) -> Non clear=True, ): roots = set(guard_allow_roots()) - self.assertIn("/etc/resolv.conf", roots) - self.assertIn("/etc/hosts", roots) - self.assertIn("/usr", roots) - self.assertIn("/usr/local/sdkman/candidates", roots) - self.assertIn("/etc/gitconfig", roots) - self.assertIn("/etc/gitconfig.d", roots) + if os.name != "nt": + for root in ( + "/etc/resolv.conf", "/etc/hosts", "/usr", + "/usr/local/sdkman/candidates", "/etc/gitconfig", + "/etc/gitconfig.d", + ): + self.assertIn(root, roots) self.assertIn(str(java_home.resolve()), roots) self.assertIn(str(explicit_root.resolve()), roots) self.assertNotIn(str(private_path_dir.resolve()), roots) @@ -768,9 +814,9 @@ def test_safe_exec_git_init_and_local_config_reads_system_git_config_roots(self) if shutil.which("git") is None: self.skipTest("git is not available") with TemporaryDirectory() as tmp: - workspace = Path(tmp) - runtime = Runtime(workspace) - with patch.dict(server_module.os.environ, {"PATH": os.environ.get("PATH", "")}, clear=True): + runtime = Runtime(Path(tmp)) + command_env = {"PATH": os.environ.get("PATH", ""), **windows_shell_env()} + with patch.dict(server_module.os.environ, command_env, clear=True): self.assertNotIn("GIT_CONFIG_NOSYSTEM", runtime._command_env({})) result = runtime.exec_command( { @@ -784,9 +830,12 @@ def test_safe_exec_git_init_and_local_config_reads_system_git_config_roots(self) "max_output_bytes": 20000, } ) + runtime.close() self.assertEqual(result.get("status"), "exited", result) self.assertEqual(result.get("exit_code"), 0, result) - self.assertNotIn("unable to access '/etc/gitconfig'", str(result.get("stderr", ""))) + self.assertNotIn( + "unable to access '/etc/gitconfig'", str(result.get("stderr", "")) + ) def test_exec_diagnostics_classify_common_failures(self) -> None: self.assertEqual( @@ -989,13 +1038,32 @@ def test_exec_truncation_names_both_stream_continuations(self) -> None: def test_exec_running_model_text_names_the_poll_call(self) -> None: with TemporaryDirectory() as tmp: - result = Runtime(Path(tmp), permission_mode="trusted").call_tool( + runtime = Runtime(Path(tmp), permission_mode="trusted") + result = runtime.call_tool( "exec_command", - {"cmd": "sleep 1", "timeout_ms": 10000, "yield_time_ms": 0}, + { + "cmd": python_shell_command("import time; time.sleep(0.2)"), + "timeout_ms": 10000, + "yield_time_ms": 0, + }, ) - model_text = self.agent_text(result) - self.assertIn("Status: running", model_text) - self.assertIn('write_stdin(session_id="', model_text) + try: + model_text = self.agent_text(result) + self.assertIn("Status: running", model_text) + self.assertIn('write_stdin(session_id="', model_text) + finally: + structured = result.get("structuredContent", {}) + session_id = structured.get("session_id") if isinstance(structured, dict) else None + if isinstance(session_id, str): + completed = runtime.write_stdin( + { + "session_id": session_id, + "chars": "", + "yield_time_ms": 2000, + } + ) + self.assertNotEqual(completed.get("status"), "running") + runtime.close() def test_read_file_truncation_is_visible_with_continuation(self) -> None: with TemporaryDirectory() as tmp: @@ -1142,15 +1210,24 @@ def test_active_process_limit_counts_running_commands(self) -> None: with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") session_ids: list[str] = [] + command = python_shell_command("import time; time.sleep(5)") try: for _ in range(MAX_ACTIVE_EXEC_SESSIONS): result = runtime.exec_command( - {"cmd": "sleep 5", "timeout_ms": 10_000, "yield_time_ms": 0} + { + "cmd": command, + "timeout_ms": 10_000, + "yield_time_ms": 0, + } ) session_ids.append(str(result["session_id"])) with self.assertRaises(ToolFailure) as raised: runtime.exec_command( - {"cmd": "sleep 5", "timeout_ms": 10_000, "yield_time_ms": 0} + { + "cmd": command, + "timeout_ms": 10_000, + "yield_time_ms": 0, + } ) self.assertEqual(raised.exception.code, "SESSION_LIMIT_REACHED") finally: @@ -1183,26 +1260,33 @@ def test_exec_command_compact_preview_and_read_output(self) -> None: runtime = Runtime(Path(tmp), permission_mode="trusted") result = runtime.exec_command( { - "cmd": "printf 'alpha\nbeta\n'", + "cmd": python_shell_command( + "import sys; sys.stdout.buffer.write(b'alpha\\nbeta\\n')" + ), "timeout_ms": 5000, "yield_time_ms": 30000, "verbosity": "preview", "preview_bytes": 64, } ) - self.assertEqual(result.get("status"), "exited", result) - self.assertEqual(result.get("exit_code"), 0, result) - self.assertIn("summary", result) - self.assertIn("preview", result) - self.assertIn("output_ref", result) - self.assertIn("output_refs", result) - self.assertEqual(result.get("output_stream"), "stdout") - self.assertNotIn("stdout", result) - page = runtime.read_output({"output_ref": result["output_ref"], "offset": 0, "limit": 128}) - self.assertIn("alpha", page.get("content", "")) - self.assertIn("beta", page.get("content", "")) - self.assertEqual(page.get("stream"), "stdout") - self.assertIsNone(page.get("next_offset")) + try: + self.assertEqual(result.get("status"), "exited", result) + self.assertEqual(result.get("exit_code"), 0, result) + self.assertIn("summary", result) + self.assertIn("preview", result) + self.assertIn("output_ref", result) + self.assertIn("output_refs", result) + self.assertEqual(result.get("output_stream"), "stdout") + self.assertNotIn("stdout", result) + page = runtime.read_output( + {"output_ref": result["output_ref"], "offset": 0, "limit": 128} + ) + self.assertIn("alpha", page.get("content", "")) + self.assertIn("beta", page.get("content", "")) + self.assertEqual(page.get("stream"), "stdout") + self.assertIsNone(page.get("next_offset")) + finally: + runtime.close() @unittest.skipIf(os.name == "nt", "this build explicitly reports ConPTY as unsupported") def test_exec_command_tty_uses_a_real_pseudo_terminal(self) -> None: @@ -1267,64 +1351,99 @@ def test_completed_sessions_are_evicted_from_active_storage(self) -> None: def test_running_and_truncated_commands_return_explicit_next_actions(self) -> None: with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") - running = runtime.exec_command( - {"cmd": "sleep 1", "timeout_ms": 5000, "yield_time_ms": 0, "max_output_bytes": 64} - ) - self.assertEqual(running.get("status"), "running") - self.assertEqual(running.get("next_action", {}).get("tool"), "write_stdin") - runtime.kill_session({"session_id": running["session_id"], "signal": "KILL"}) + try: + running = runtime.exec_command( + { + "cmd": python_shell_command("import time; time.sleep(1)"), + "timeout_ms": 5000, + "yield_time_ms": 0, + "max_output_bytes": 64, + } + ) + self.assertEqual(running.get("status"), "running") + self.assertEqual( + running.get("next_action", {}).get("tool"), "write_stdin" + ) + runtime.kill_session( + { + "session_id": running["session_id"], + "signal": "KILL", + "wait_ms": 5000, + } + ) - truncated = runtime.exec_command( - { - "cmd": "printf 'abcdefghijklmnopqrstuvwxyz'", - "timeout_ms": 5000, - "yield_time_ms": 5000, - "max_output_bytes": 8, - } - ) - self.assertTrue(truncated.get("output_truncated"), truncated) - self.assertEqual(truncated.get("next_action", {}).get("tool"), "read_output") - self.assertIn("output_ref", truncated) + truncated = runtime.exec_command( + { + "cmd": python_shell_command( + "import sys; " + "sys.stdout.buffer.write(b'abcdefghijklmnopqrstuvwxyz')" + ), + "timeout_ms": 5000, + "yield_time_ms": 5000, + "max_output_bytes": 8, + } + ) + self.assertTrue(truncated.get("output_truncated"), truncated) + self.assertEqual( + truncated.get("next_action", {}).get("tool"), "read_output" + ) + self.assertIn("output_ref", truncated) + finally: + runtime.close() def test_read_output_pages_streams_independently(self) -> None: with TemporaryDirectory() as tmp: runtime = Runtime(Path(tmp), permission_mode="trusted") script = ( "import sys,time;" - "sys.stderr.write('err1\\nerr2\\n'); sys.stderr.flush();" - "sys.stdout.write('out1\\n'); sys.stdout.flush();" + "sys.stderr.buffer.write(b'err1\\nerr2\\n'); sys.stderr.flush();" + "sys.stdout.buffer.write(b'out1\\n'); sys.stdout.flush();" "time.sleep(0.4);" - "sys.stdout.write('out2\\n'); sys.stdout.flush();" + "sys.stdout.buffer.write(b'out2\\n'); sys.stdout.flush();" "time.sleep(1)" ) result = runtime.exec_command( { - "cmd": f"{sys.executable} -c {script!r}", + "cmd": python_shell_command(script), "timeout_ms": 5000, "yield_time_ms": 100, "verbosity": "preview", "preview_bytes": 64, } ) - self.assertEqual(result.get("status"), "running", result) - output_refs = result.get("output_refs") - self.assertIsInstance(output_refs, dict) - stderr_ref = output_refs["stderr"] - - first: dict[str, object] = {} - for _ in range(10): - first = runtime.read_output({"output_ref": stderr_ref, "offset": 0, "limit": 5}) - if first.get("content"): - break - time.sleep(0.05) - self.assertEqual(first.get("content"), "err1\n") - self.assertEqual(first.get("next_offset"), 5) - time.sleep(0.6) - second = runtime.read_output({"output_ref": stderr_ref, "offset": first["next_offset"], "limit": 64}) - self.assertEqual(second.get("offset"), first.get("next_offset")) - self.assertEqual(second.get("content"), "err2\n") - self.assertNotIn("out2", second.get("content", "")) - runtime.kill_session({"session_id": result["session_id"], "wait_ms": 1000}) + try: + self.assertEqual(result.get("status"), "running", result) + output_refs = result.get("output_refs") + self.assertIsInstance(output_refs, dict) + assert isinstance(output_refs, dict) + stderr_ref = output_refs["stderr"] + + first: dict[str, object] = {} + for _ in range(10): + first = runtime.read_output( + {"output_ref": stderr_ref, "offset": 0, "limit": 5} + ) + if first.get("content"): + break + time.sleep(0.05) + self.assertEqual(first.get("content"), "err1\n") + self.assertEqual(first.get("next_offset"), 5) + time.sleep(0.6) + second = runtime.read_output( + { + "output_ref": stderr_ref, + "offset": first["next_offset"], + "limit": 64, + } + ) + self.assertEqual(second.get("offset"), first.get("next_offset")) + self.assertEqual(second.get("content"), "err2\n") + self.assertNotIn("out2", second.get("content", "")) + finally: + runtime.kill_session( + {"session_id": result["session_id"], "wait_ms": 1000} + ) + runtime.close() def test_read_output_uses_absolute_stream_offsets_after_buffer_drop(self) -> None: with TemporaryDirectory() as tmp: @@ -1355,7 +1474,9 @@ def test_default_cwd_and_git_convenience_tools(self) -> None: with TemporaryDirectory() as tmp: workspace = Path(tmp) (workspace / "src").mkdir() - (workspace / "src" / "hello.txt").write_text("hello\n", encoding="utf-8") + (workspace / "src" / "hello.txt").write_text( + "hello\n", encoding="utf-8", newline="\n" + ) for cmd in ( ["git", "init", "-q"], ["git", "config", "user.email", "test@example.invalid"], @@ -1363,61 +1484,114 @@ def test_default_cwd_and_git_convenience_tools(self) -> None: ["git", "add", "-A"], ["git", "commit", "-q", "-m", "initial commit"], ): - completed = subprocess.run(cmd, cwd=workspace, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + completed = subprocess.run( + cmd, + cwd=workspace, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) if completed.returncode != 0: - self.skipTest(f"git fixture setup failed: {completed.stderr.strip()}") + self.skipTest( + f"git fixture setup failed: {completed.stderr.strip()}" + ) runtime = Runtime(workspace) - cwd = runtime.set_default_cwd({"path": "src"}) - self.assertEqual(cwd.get("default_cwd"), "src") - read = runtime.read_file({"path": "hello.txt"}) - self.assertEqual(read.get("content"), "hello\n") - - log = runtime.git_log({"max_count": 5}) - self.assertTrue(log.get("is_repo")) - self.assertEqual(log.get("commits", [])[0].get("subject"), "initial commit") + try: + cwd = runtime.set_default_cwd({"path": "src"}) + self.assertEqual(cwd.get("default_cwd"), "src") + read = runtime.read_file({"path": "hello.txt"}) + self.assertEqual(read.get("content"), "hello\n") + + log = runtime.git_log({"max_count": 5}) + self.assertTrue(log.get("is_repo")) + self.assertEqual( + log.get("commits", [])[0].get("subject"), "initial commit" + ) - show = runtime.git_show({"include_diff": False, "max_bytes": 4096}) - self.assertTrue(show.get("is_repo")) - self.assertIn("initial commit", show.get("content", "")) + show = runtime.git_show({"include_diff": False, "max_bytes": 4096}) + self.assertTrue(show.get("is_repo")) + self.assertIn("initial commit", show.get("content", "")) - blame = runtime.git_blame({"path": "hello.txt", "max_lines": 5}) - self.assertTrue(blame.get("is_repo")) - self.assertEqual(blame.get("lines", [])[0].get("content"), "hello") + blame = runtime.git_blame({"path": "hello.txt", "max_lines": 5}) + self.assertTrue(blame.get("is_repo")) + self.assertEqual( + blame.get("lines", [])[0].get("content"), "hello" + ) - with self.assertRaises(ToolFailure): - runtime.set_default_cwd({"path": "../outside"}) + with self.assertRaises(ToolFailure): + runtime.set_default_cwd({"path": "../outside"}) + finally: + runtime.close() def test_boundary_regressions_for_aliases_and_command_scanning(self) -> None: with TemporaryDirectory() as tmp: workspace = Path(tmp) (workspace / "nested").mkdir() - (workspace / "sample.txt").write_text("one\ntwo\nthree\n", encoding="utf-8") + (workspace / "sample.txt").write_text( + "one\ntwo\nthree\n", encoding="utf-8", newline="\n" + ) runtime = Runtime(workspace, permission_mode="trusted") + try: + cwd_result = runtime.exec_command( + { + "cmd": python_shell_command("import os; print(os.getcwd())"), + "cwd": "nested", + "timeout_ms": 5000, + "max_output_bytes": 4096, + } + ) + self.assertEqual(cwd_result.get("exit_code"), 0) + self.assertEqual( + Path(str(cwd_result.get("stdout", "")).strip()).name, + "nested", + ) - cwd_result = runtime.exec_command( - {"cmd": "pwd", "cwd": "nested", "timeout_ms": 5000, "max_output_bytes": 4096} - ) - self.assertEqual(cwd_result.get("exit_code"), 0) - self.assertEqual(Path(str(cwd_result.get("stdout", "")).strip()).name, "nested") + with self.assertRaises(ToolFailure): + runtime.exec_command( + { + "cmd": python_shell_command( + "import os; print(os.getcwd())" + ), + "workdir": ".", + "cwd": "nested", + } + ) - with self.assertRaises(ToolFailure): - runtime.exec_command({"cmd": "pwd", "workdir": ".", "cwd": "nested"}) - - read = runtime.read_file({"path": "sample.txt", "start_line": 2, "max_lines": 1}) - self.assertEqual(read.get("content"), "two\n") - self.assertEqual(read.get("end_line"), 2) - - tag = "model" + "Version" - xml_heredoc = ( - "cat > pom.xml <<'EOF'\n" - "\n" - f" <{tag}>4.0.0\n" - "\n" - "EOF" - ) - runtime.exec_command({"cmd": xml_heredoc, "timeout_ms": 5000, "max_output_bytes": 4096}) - self.assertIn(tag, (workspace / "pom.xml").read_text(encoding="utf-8")) + read = runtime.read_file( + {"path": "sample.txt", "start_line": 2, "max_lines": 1} + ) + self.assertEqual(read.get("content"), "two\n") + self.assertEqual(read.get("end_line"), 2) + + tag = "model" + "Version" + xml_heredoc = ( + "cat > pom.xml <<'EOF'\n" + "\n" + f" <{tag}>4.0.0\n" + "\n" + "EOF" + ) + if os.name == "nt": + runtime._check_command_policy(xml_heredoc, {}) + (workspace / "pom.xml").write_text( + f"\n <{tag}>4.0.0\n\n", + encoding="utf-8", + newline="\n", + ) + else: + runtime.exec_command( + { + "cmd": xml_heredoc, + "timeout_ms": 5000, + "max_output_bytes": 4096, + } + ) + self.assertIn( + tag, (workspace / "pom.xml").read_text(encoding="utf-8") + ) + finally: + runtime.close() def test_heredoc_payload_stripping_keeps_live_shell_code_scanned(self) -> None: with TemporaryDirectory() as tmp: diff --git a/tests/compliance/test_upstream_gateway.py b/tests/compliance/test_upstream_gateway.py new file mode 100644 index 0000000..23416d6 --- /dev/null +++ b/tests/compliance/test_upstream_gateway.py @@ -0,0 +1,557 @@ +from __future__ import annotations + +import copy +import inspect +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp import upstream as upstream_module +from coding_tools_mcp.server import ( + AuthorizationContext, + Runtime, + TOOL_REGISTRY, + build_parser, + load_upstream_startup, + server_card_payload, +) +from coding_tools_mcp.upstream import ( + BaseUpstreamClient, + HttpUpstreamClient, + UpstreamConfigError, + UpstreamError, + UpstreamManager, + UpstreamServerConfig, + base_upstream_environment, + _rpc_result, + decode_http_rpc_response, + load_upstream_config_snapshot, + normalize_tool_result, + parse_server_config, + resolve_env_config, +) +from coding_tools_mcp.workspace_binding import WorkspaceBinding + + +REMOTE_TOOLS = [ + { + "name": "search", + "title": "Remote Search", + "description": "Search remote repositories.", + "inputSchema": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + "$defs": {"query": {"type": "string"}}, + }, + "outputSchema": { + "type": "object", + "properties": {"hits": {"type": "array"}}, + }, + "annotations": { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + }, + }, + { + "name": "create_issue", + "description": "Create an issue on the remote service.", + "inputSchema": {"type": "object", "additionalProperties": True}, + "annotations": {"readOnlyHint": False, "destructiveHint": True}, + }, +] + + +class FakeUpstreamClient(BaseUpstreamClient): + def __init__( + self, + config: UpstreamServerConfig, + protocol_version: str, + *, + tools: list[dict[str, object]] | None = None, + behavior: str = "success", + marker: str = "remote", + ) -> None: + super().__init__(config, protocol_version) + self.tools = copy.deepcopy(tools if tools is not None else REMOTE_TOOLS) + self.behavior = behavior + self.marker = marker + self.calls: list[tuple[str, dict[str, object]]] = [] + self.closed = False + + def initialize(self) -> None: + return None + + def list_tools(self) -> list[dict[str, object]]: + return copy.deepcopy(self.tools) + + def call_tool(self, name: str, arguments: dict[str, object]) -> dict[str, object]: + self.calls.append((name, copy.deepcopy(arguments))) + if self.behavior == "timeout": + raise UpstreamError( + "UPSTREAM_TIMEOUT", + "Timed out waiting for upstream MCP server.", + retryable=True, + ) + if self.behavior == "disconnect": + raise UpstreamError( + "UPSTREAM_DISCONNECTED", + "Upstream MCP server disconnected.", + retryable=True, + ) + if self.behavior == "rpc_error": + raise UpstreamError( + "UPSTREAM_RPC_ERROR", + "Remote tool rejected the request.", + category="upstream", + details={"method": "tools/call", "rpc_error": {"code": -32001, "message": "denied"}}, + ) + if self.behavior == "protocol": + raise UpstreamError( + "UPSTREAM_PROTOCOL_ERROR", + "Upstream response envelope was invalid.", + category="protocol", + ) + return { + "content": [ + {"type": "text", "text": f"called {name}"}, + {"type": "resource_link", "uri": "https://remote.example/item/1", "name": "item"}, + ], + "structuredContent": { + "ok": True, + "marker": self.marker, + "remote_name": name, + "arguments": copy.deepcopy(arguments), + }, + "isError": False, + } + + def request(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + raise AssertionError("Fake client does not use raw request().") + + def notify(self, method: str, params: dict[str, object] | None = None) -> None: + raise AssertionError("Fake client does not use raw notify().") + + def close(self) -> None: + self.closed = True + + +def build_manager( + configs: list[UpstreamServerConfig], + clients: list[FakeUpstreamClient], + *, + reserved_names: set[str] | None = None, +) -> UpstreamManager: + pending = list(clients) + + def fake_build_client( + config: UpstreamServerConfig, + protocol_version: str, + secret_resolver: object | None = None, + ) -> FakeUpstreamClient: + del secret_resolver + if not pending: + raise AssertionError(f"Unexpected client creation for {config.alias}") + client = pending.pop(0) + self_config = client.config + if self_config.alias != config.alias: + raise AssertionError(f"Expected {self_config.alias}, got {config.alias}") + return client + + with patch.object(upstream_module, "build_client", side_effect=fake_build_client): + manager = UpstreamManager( + configs, + reserved_names=reserved_names or set(TOOL_REGISTRY), + ) + return manager + + +class UpstreamGatewayTests(unittest.TestCase): + def test_runtime_preserves_schema_annotations_and_structured_content(self) -> None: + with TemporaryDirectory() as tmp: + config = UpstreamServerConfig( + alias="github", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25") + manager = build_manager([config], [client]) + runtime = Runtime(Path(tmp), upstream_manager=manager) + + definitions = {item["name"]: item for item in runtime.list_tools()["tools"]} + remote = definitions["github__search"] + original = REMOTE_TOOLS[0] + + self.assertIn("server_info", definitions) + self.assertEqual(remote["title"], original["title"]) + self.assertEqual(remote["description"], original["description"]) + self.assertEqual(remote["inputSchema"], original["inputSchema"]) + self.assertEqual(remote["outputSchema"], original["outputSchema"]) + self.assertEqual(remote["annotations"], original["annotations"]) + + result = runtime.call_tool("github__search", {"q": "mcp"}) + + self.assertFalse(result["isError"]) + self.assertEqual(result["structuredContent"]["remote_name"], "search") + self.assertEqual(result["content"][1]["type"], "resource_link") + self.assertEqual(client.calls, [("search", {"q": "mcp"})]) + runtime.close() + self.assertTrue(client.closed) + + def test_nested_namespace_is_stable_and_local_names_remain_reserved(self) -> None: + nested = copy.deepcopy(REMOTE_TOOLS[0]) + nested["name"] = "inner__search" + config = UpstreamServerConfig( + alias="outer", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25", tools=[nested]) + manager = build_manager([config], [client]) + self.assertEqual(manager.tool_names(), ["outer__inner__search"]) + + collision_client = FakeUpstreamClient(config, "2025-11-25", tools=[nested]) + with self.assertRaisesRegex(UpstreamConfigError, "namespace collision"): + build_manager( + [config], + [collision_client], + reserved_names={"outer__inner__search"}, + ) + self.assertTrue(collision_client.closed) + + def test_fake_readonly_never_rewrites_upstream_annotations(self) -> None: + with TemporaryDirectory() as tmp: + config = UpstreamServerConfig( + alias="github", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25") + runtime = Runtime( + Path(tmp), + permission_mode="dangerous", + fake_readonly_annotations=True, + upstream_manager=build_manager([config], [client]), + ) + definitions = {item["name"]: item for item in runtime.list_tools()["tools"]} + + self.assertTrue(definitions["exec_command"]["annotations"]["readOnlyHint"]) + self.assertEqual( + definitions["github__create_issue"]["annotations"], + REMOTE_TOOLS[1]["annotations"], + ) + card = server_card_payload(runtime) + self.assertIn("github__create_issue", card["tools"]["readOnlyHintFalse"]) + runtime.close() + + def test_http_transport_maps_timeout_disconnect_and_connection_failure(self) -> None: + config = UpstreamServerConfig( + alias="http", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = HttpUpstreamClient(config, "2025-11-25") + cases = ( + (TimeoutError("timed out"), "UPSTREAM_TIMEOUT"), + (upstream_module.RemoteDisconnected("closed"), "UPSTREAM_DISCONNECTED"), + (upstream_module.urllib.error.URLError("refused"), "UPSTREAM_CONNECTION_FAILED"), + ) + for failure, expected_code in cases: + with self.subTest(expected_code=expected_code): + with patch.object( + upstream_module.urllib.request, + "urlopen", + side_effect=failure, + ): + with self.assertRaises(UpstreamError) as caught: + client.request("tools/list", {}) + self.assertEqual(caught.exception.code, expected_code) + self.assertTrue(caught.exception.retryable) + + def test_timeout_disconnect_rpc_and_protocol_errors_are_structured(self) -> None: + for behavior, expected_code, expected_category in ( + ("timeout", "UPSTREAM_TIMEOUT", "runtime"), + ("disconnect", "UPSTREAM_DISCONNECTED", "runtime"), + ("rpc_error", "UPSTREAM_RPC_ERROR", "upstream"), + ("protocol", "UPSTREAM_PROTOCOL_ERROR", "protocol"), + ): + with self.subTest(behavior=behavior): + config = UpstreamServerConfig( + alias=behavior, + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25", behavior=behavior) + manager = build_manager([config], [client]) + + result = manager.call_tool(f"{behavior}__search", {"q": "mcp"}) + + self.assertTrue(result["isError"]) + structured = result["structuredContent"] + self.assertFalse(structured["ok"]) + self.assertEqual(structured["error"]["code"], expected_code) + self.assertEqual(structured["error"]["category"], expected_category) + self.assertEqual(structured["upstream_alias"], behavior) + + def test_snapshot_and_list_changed_false_remain_true_after_remote_changes(self) -> None: + with TemporaryDirectory() as tmp: + config = UpstreamServerConfig( + alias="github", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(config, "2025-11-25") + runtime = Runtime(Path(tmp), upstream_manager=build_manager([config], [client])) + first = runtime.list_tools() + + client.tools.clear() + client.tools.append( + { + "name": "new_after_initialize", + "inputSchema": {"type": "object"}, + "annotations": {"readOnlyHint": True}, + } + ) + second = runtime.list_tools() + initialized = runtime.initialize({"name": "test", "version": "1"}) + + self.assertEqual(first, second) + self.assertFalse(initialized["capabilities"]["tools"]["listChanged"]) + self.assertNotIn("github__new_after_initialize", runtime.exposed_tool_names()) + self.assertFalse(hasattr(runtime.upstream_manager, "start_server")) + self.assertFalse(hasattr(runtime.upstream_manager, "stop_server")) + runtime.close() + + def test_enable_state_and_allowlist_are_applied_before_initialization(self) -> None: + enabled = UpstreamServerConfig( + alias="enabled", + transport="streamable_http", + url="http://127.0.0.1/mcp", + include_tools=("search",), + ) + disabled = UpstreamServerConfig( + alias="disabled", + transport="streamable_http", + enabled=False, + url="http://127.0.0.1/mcp", + ) + client = FakeUpstreamClient(enabled, "2025-11-25") + manager = build_manager([enabled, disabled], [client]) + + self.assertEqual(manager.tool_names(), ["enabled__search"]) + status = manager.status_payload() + disabled_status = next(item for item in status["servers"] if item["alias"] == "disabled") + self.assertFalse(disabled_status["initialized"]) + self.assertEqual(status["tool_count"], 1) + self.assertTrue(status["snapshot_immutable"]) + self.assertEqual(status["remote_capability_boundary"], "upstream_server") + + def test_two_runtimes_do_not_share_upstream_client_state_or_session_identity(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = UpstreamServerConfig( + alias="remote", + transport="streamable_http", + url="http://127.0.0.1/mcp", + ) + first_client = FakeUpstreamClient(config, "2025-11-25", marker="first") + second_client = FakeUpstreamClient(config, "2025-11-25", marker="second") + first_binding = WorkspaceBinding("workspace-a", root, "oauth") + second_binding = WorkspaceBinding("workspace-a", root, "oauth") + first_context = AuthorizationContext("oauth") + second_context = AuthorizationContext("oauth") + first = Runtime( + root, + workspace_binding=first_binding, + authorization_context=first_context, + upstream_manager=build_manager([config], [first_client]), + ) + second = Runtime( + root, + workspace_binding=second_binding, + authorization_context=second_context, + upstream_manager=build_manager([config], [second_client]), + ) + first_key = first.session_authorization_key() + second_key = second.session_authorization_key() + + first_result = first.call_tool("remote__search", {"q": "one"}) + second_result = second.call_tool("remote__search", {"q": "two"}) + + self.assertEqual(first_result["structuredContent"]["marker"], "first") + self.assertEqual(second_result["structuredContent"]["marker"], "second") + self.assertEqual(first_client.calls, [("search", {"q": "one"})]) + self.assertEqual(second_client.calls, [("search", {"q": "two"})]) + self.assertEqual(first.session_authorization_key(), first_key) + self.assertEqual(second.session_authorization_key(), second_key) + first.close() + second.close() + + def test_startup_loads_default_and_explicit_gateway_config_before_runtime(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + default_path = root / "mcp-servers.json" + default_path.write_text( + json.dumps( + { + "servers": { + "default": { + "transport": "streamable_http", + "url": "http://127.0.0.1/default", + "enabled": False, + } + } + } + ), + encoding="utf-8", + ) + with patch.dict( + upstream_module.os.environ, + {"CODING_TOOLS_MCP_UPSTREAM_CONFIG": ""}, + clear=False, + ): + args = build_parser().parse_args([]) + snapshot = load_upstream_startup(args, root) + self.assertEqual(snapshot.configs[0].alias, "default") + + explicit_path = root / "explicit.json" + explicit_path.write_text( + json.dumps( + { + "servers": { + "explicit": { + "transport": "streamable_http", + "url": "http://127.0.0.1/explicit", + "enabled": False, + } + } + } + ), + encoding="utf-8", + ) + args = build_parser().parse_args(["--upstream-config", str(explicit_path)]) + explicit = load_upstream_startup(args, root) + self.assertEqual(explicit.configs[0].alias, "explicit") + + missing = build_parser().parse_args( + ["--upstream-config", str(root / "missing.json")] + ) + with self.assertRaises(UpstreamConfigError): + load_upstream_startup(missing, root) + + def test_config_is_strict_and_contains_no_runtime_profile_control(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "mcp-servers.json" + path.write_text( + json.dumps( + { + "servers": { + "github": { + "transport": "streamable_http", + "url": "http://127.0.0.1/mcp", + "enabled": True, + "include_tools": ["search"], + } + } + } + ), + encoding="utf-8", + ) + snapshot = load_upstream_config_snapshot(path) + + self.assertEqual(snapshot.configs[0].include_tools, ("search",)) + self.assertNotIn("tool_profile", inspect.getsource(upstream_module)) + self.assertNotIn("tool_profile", inspect.signature(UpstreamManager.tool_names).parameters) + self.assertNotIn("tool_profile", inspect.signature(Runtime).parameters) + with self.assertRaises(UpstreamConfigError): + parse_server_config( + "bad__alias", + {"transport": "streamable_http", "url": "http://127.0.0.1/mcp"}, + ) + with self.assertRaisesRegex(UpstreamConfigError, "include and exclude"): + parse_server_config( + "github", + { + "transport": "streamable_http", + "url": "http://127.0.0.1/mcp", + "include_tools": ["search"], + "exclude_tools": ["search"], + }, + ) + + def test_stdio_base_environment_does_not_inherit_server_secrets(self) -> None: + with patch.dict( + upstream_module.os.environ, + { + "PATH": "synthetic-path", + "HOME": "synthetic-home", + "CODING_TOOLS_MCP_OAUTH_PASSWORD": "secret-canary", + "UNRELATED_SECRET": "another-secret", + }, + clear=True, + ): + env = base_upstream_environment() + self.assertEqual(env["PATH"], "synthetic-path") + self.assertEqual(env["HOME"], "synthetic-home") + self.assertNotIn("CODING_TOOLS_MCP_OAUTH_PASSWORD", env) + self.assertNotIn("UNRELATED_SECRET", env) + self.assertNotIn("secret-canary", repr(env)) + with self.assertRaisesRegex(UpstreamConfigError, "secret_ref"): + resolve_env_config({"TOKEN": {"secret_ref": "gateway/token"}}) + + def test_json_rpc_envelopes_and_sse_are_validated_strictly(self) -> None: + invalid = [ + None, + {"jsonrpc": "1.0", "id": 7, "result": {}}, + {"jsonrpc": "2.0", "id": 8, "result": {}}, + {"jsonrpc": "2.0", "id": 7, "result": {}, "error": {"message": "both"}}, + {"jsonrpc": "2.0", "id": 7}, + {"jsonrpc": "2.0", "id": 7, "error": {"code": -1}}, + ] + for envelope in invalid: + with self.subTest(envelope=envelope): + with self.assertRaises(UpstreamError) as caught: + _rpc_result(envelope, 7, "tools/call") + self.assertEqual(caught.exception.code, "UPSTREAM_PROTOCOL_ERROR") + + with self.assertRaises(UpstreamError) as caught: + _rpc_result( + { + "jsonrpc": "2.0", + "id": 7, + "error": {"code": -32001, "message": "remote denied", "data": {"reason": "policy"}}, + }, + 7, + "tools/call", + ) + self.assertEqual(caught.exception.code, "UPSTREAM_RPC_ERROR") + self.assertEqual(caught.exception.details["rpc_error"]["data"]["reason"], "policy") + + sse = ( + b'data: {"jsonrpc":"2.0","method":"notifications/progress"}\n\n' + b'data: {"jsonrpc":"2.0","id":7,"result":{"tools":[]}}\n\n' + ) + parsed = decode_http_rpc_response(sse, "text/event-stream", expected_id=7) + self.assertEqual(parsed["id"], 7) + with self.assertRaises(json.JSONDecodeError): + decode_http_rpc_response(b"not-json", "application/json", expected_id=7) + + def test_content_boundary_does_not_serialize_structured_content_as_text(self) -> None: + result = normalize_tool_result( + { + "structuredContent": {"answer": 42}, + "isError": False, + } + ) + self.assertEqual(result["content"], []) + self.assertEqual(result["structuredContent"], {"answer": 42}) + with self.assertRaisesRegex(UpstreamError, "content"): + normalize_tool_result({"content": "not-an-array"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integration_contract_v022.py b/tests/test_integration_contract_v022.py new file mode 100644 index 0000000..57c295c --- /dev/null +++ b/tests/test_integration_contract_v022.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import inspect +import json +import re +import sqlite3 +import unittest + +from coding_tools_mcp.admin import AdminService +from pathlib import Path +from contextlib import closing +from tempfile import TemporaryDirectory + +from coding_tools_mcp import codex_sessions as codex_sessions_module +from coding_tools_mcp import transcript as transcript_module +from coding_tools_mcp import upstream as upstream_module +from coding_tools_mcp.oauth import ( + OAUTH_GRANT_TYPES_SUPPORTED, + OAUTH_RESPONSE_TYPES_SUPPORTED, + OAuthClientRegistry, + OAuthIdentity, +) +from coding_tools_mcp.protocol import PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore +from coding_tools_mcp.server import MCPHandler, Runtime, TOOL_REGISTRY, build_parser +from coding_tools_mcp.upstream import UpstreamManager +from coding_tools_mcp.workspace_binding import WorkspaceBindingError, WorkspaceBindingResolver +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry +from tests.test_oauth_store import oauth_root + +from coding_tools_mcp.settings_definition import ( + LEGACY_TOOL_PROFILE_WARNING, + migrate_persisted_settings, +) + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT_PATH = ROOT / "docs" / "integration-contract-v0.2.2.md" +CONTRACT_PATTERN = re.compile( + r"\s*```json\s*(\{.*?\})\s*```\s*" + r"", + re.DOTALL, +) + + +def load_contract() -> dict[str, object]: + text = CONTRACT_PATH.read_text(encoding="utf-8") + match = CONTRACT_PATTERN.search(text) + if match is None: + raise AssertionError("integration contract JSON block is missing") + payload = json.loads(match.group(1)) + if not isinstance(payload, dict): + raise AssertionError("integration contract JSON must be an object") + return payload + + +class IntegrationContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.contract = load_contract() + + def test_protocol_target_matches_upstream_runtime(self) -> None: + protocol = self.contract["protocol"] + self.assertIsInstance(protocol, dict) + self.assertEqual(protocol["target"], PROTOCOL_VERSION) + self.assertEqual( + [protocol["target"], *protocol["compatible"]], + list(SUPPORTED_PROTOCOL_VERSIONS), + ) + self.assertEqual(self.contract["version"], {"integration": "0.2.2"}) + + def test_catalog_is_fixed_and_legacy_profiles_are_migration_only(self) -> None: + catalog = self.contract["tool_catalog"] + self.assertIsInstance(catalog, dict) + self.assertEqual(catalog["strategy"], "fixed") + self.assertEqual(catalog["source"], "coding_tools_mcp.server.TOOL_REGISTRY") + self.assertIs(catalog["legacy_tool_profile_controls_catalog"], False) + self.assertNotIn("--tool-profile", build_parser().format_help()) + + with TemporaryDirectory() as tmp: + truthful = Runtime(Path(tmp), permission_mode="dangerous") + compatibility = Runtime( + Path(tmp), + permission_mode="dangerous", + fake_readonly_annotations=True, + ) + try: + truthful_tools = truthful.list_tools()["tools"] + compatibility_tools = compatibility.list_tools()["tools"] + finally: + truthful.close() + compatibility.close() + + self.assertEqual( + {tool["name"] for tool in truthful_tools}, + {tool["name"] for tool in compatibility_tools}, + ) + self.assertEqual({tool["name"] for tool in truthful_tools}, set(TOOL_REGISTRY)) + fake_policy = catalog["fake_readonly"] + self.assertIs(fake_policy["security_boundary"], False) + self.assertIs(fake_policy["changes_catalog"], False) + self.assertIs(fake_policy["changes_handlers"], False) + for tool in compatibility_tools: + annotations = tool["annotations"] + self.assertIs(annotations["readOnlyHint"], True) + self.assertIs(annotations["destructiveHint"], False) + self.assertIs(annotations["openWorldHint"], False) + + def test_legacy_tool_profile_migration_inputs_and_outputs_are_explicit(self) -> None: + migration = self.contract["legacy_tool_profile_migration"] + self.assertIsInstance(migration, dict) + self.assertIs(migration["persist_on_next_write"], False) + self.assertEqual(migration["unknown_value"], "ignore_with_warning") + + cases = migration["cases"] + self.assertEqual( + {case["input"]["tool_profile"] for case in cases}, + {"full", "read-only", "compat-readonly-all"}, + ) + for case in cases: + with self.subTest(tool_profile=case["input"]["tool_profile"]): + output = case["output"] + self.assertIsNone(output["tool_profile"]) + self.assertEqual(output["catalog"], "fixed") + self.assertEqual(output["warning"], migration["warning_code"]) + + def test_oauth_advertising_uses_the_upstream_constant_sources(self) -> None: + oauth = self.contract["oauth"] + self.assertEqual(oauth["advertised_grant_types"], list(OAUTH_GRANT_TYPES_SUPPORTED)) + self.assertEqual(oauth["advertised_response_types"], list(OAUTH_RESPONSE_TYPES_SUPPORTED)) + self.assertEqual(oauth["grant_types_source"], "coding_tools_mcp.oauth.OAUTH_GRANT_TYPES_SUPPORTED") + self.assertEqual(oauth["response_types_source"], "coding_tools_mcp.oauth.OAUTH_RESPONSE_TYPES_SUPPORTED") + + registry = OAuthClientRegistry() + registered = registry.register( + { + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["refresh_token", "authorization_code"], + "response_types": ["code", "token"], + } + ) + self.assertEqual(registered["grant_types"], list(OAUTH_GRANT_TYPES_SUPPORTED)) + self.assertEqual(registered["response_types"], ["code"]) + + metadata_source = inspect.getsource(MCPHandler.handle_oauth_as_metadata) + token_source = inspect.getsource(MCPHandler.handle_oauth_token) + self.assertIn("OAUTH_GRANT_TYPES_SUPPORTED", metadata_source) + self.assertIn("OAUTH_RESPONSE_TYPES_SUPPORTED", metadata_source) + self.assertIn("OAUTH_GRANT_TYPE_AUTHORIZATION_CODE", token_source) + + def test_later_phase_boundaries_are_machine_readable(self) -> None: + oauth = self.contract["oauth"] + workspace = self.contract["workspace_binding"] + telemetry = self.contract["telemetry"] + secret_stores = self.contract["secret_stores"] + + self.assertEqual(oauth["persistent_store_phase"], 4) + self.assertEqual(oauth["http_integration_phase"], 5) + self.assertEqual(oauth["migration"], "idempotent_transactional") + self.assertEqual(workspace["phase"], 6) + self.assertEqual(workspace["point"], "http_initialize_runtime_factory") + self.assertIs(workspace["immutable_per_session"], True) + self.assertIs(workspace["ordinary_tool_switching"], False) + self.assertEqual(workspace["invalid_mapping"], "fail_closed") + self.assertEqual(telemetry, {"default_policy": "upstream_v0.2.2", "change_during_integration": False}) + self.assertIs(secret_stores["shared"], False) + + def test_phase08_admin_contract_is_machine_readable(self) -> None: + admin = self.contract["admin_api"] + self.assertEqual(admin["phase"], 8) + self.assertEqual(admin["authentication"], "dedicated_admin_token") + self.assertIs(admin["ordinary_mcp_bearer_is_admin"], False) + self.assertIs(admin["handler_sql"], False) + self.assertIs(admin["responses_redacted"], True) + self.assertEqual( + admin["settings_views"], + ["active", "persisted", "pending_restart"], + ) + self.assertEqual(admin["stale_update"], "revision_conflict") + self.assertEqual(admin["gateway_change"], "persist_and_restart_only") + self.assertIs(admin["gateway_dynamic_reload"], False) + self.assertEqual( + admin["allowed_origins_source"], + "coding_tools_mcp.settings_definition.normalize_allowed_origins", + ) + source = inspect.getsource(AdminService) + self.assertNotRegex(source, r"\b(?:SELECT|INSERT|UPDATE|DELETE FROM|PRAGMA)\b") + self.assertNotIn("reload_upstream", source) + + def test_phase10_webui_contract_is_machine_readable(self) -> None: + webui = self.contract["webui"] + self.assertEqual(webui["phase"], 10) + self.assertEqual(webui["source_root"], "webui/src") + self.assertEqual(webui["dist"], "build_generated_only") + self.assertEqual(webui["authentication"], "dedicated_admin_token") + self.assertEqual(webui["admin_token_storage"], "page_memory_only") + self.assertIs(webui["tool_profile_controls"], False) + self.assertIs(webui["safe_mode_hides_mutation_tools"], False) + self.assertIs(webui["fake_readonly_security_boundary"], False) + self.assertEqual( + webui["settings_stale_update"], + "preserve_draft_refresh_revision_conflict", + ) + self.assertEqual(webui["gateway_change"], "persist_and_restart_only") + self.assertIs(webui["gateway_dynamic_reload"], False) + self.assertIs(webui["secret_material_displayed"], False) + self.assertEqual(webui["conversation_list"], "summary_only") + self.assertEqual(webui["conversation_detail"], "explicit_paginated") + self.assertEqual(webui["untrusted_rendering"], "dom_text_content") + + def test_phase11_admin_telemetry_status_keeps_the_privacy_boundary(self) -> None: + telemetry = self.contract["telemetry"] + self.assertEqual( + telemetry, + {"default_policy": "upstream_v0.2.2", "change_during_integration": False}, + ) + source = inspect.getsource(AdminService.status_payload) + self.assertIn("telemetry_mode", source) + self.assertIn('"docs": "docs/telemetry.md"', source) + for forbidden in ( + "workspace_id", + "agent_id", + "client_id", + "command", + "arguments", + "file_content", + ): + with self.subTest(forbidden=forbidden): + self.assertNotIn(forbidden, source) + + def test_phase09_chat_persistence_contract_is_machine_readable(self) -> None: + chat = self.contract["chat_persistence"] + self.assertEqual(chat["phase"], 9) + self.assertIs(chat["workspace_keyed"], True) + self.assertEqual(chat["ordinary_scope"], "immutable_workspace_service") + self.assertEqual(chat["global_operations_authentication"], "dedicated_admin_token") + self.assertEqual(chat["scan_roots"], "registered_workspace_relative_only") + self.assertEqual( + chat["scan_limits"], + ["depth", "files", "file_bytes", "total_bytes", "messages"], + ) + self.assertEqual(chat["malformed_record"], "item_error_continue") + self.assertEqual(chat["list_default"], "summary_paginated") + self.assertEqual(chat["full_content"], "explicit_detail_only") + self.assertIs(chat["telemetry_content"], False) + source = inspect.getsource(transcript_module) + inspect.getsource(codex_sessions_module) + self.assertNotIn("telemetry", source.lower()) + self.assertIn("workspace_id", source) + + def test_phase03_settings_migration_drops_tool_profile(self) -> None: + migration = self.contract["legacy_tool_profile_migration"] + for case in migration["cases"]: + value = case["input"]["tool_profile"] + migrated, warnings = migrate_persisted_settings({"tool_profile": value}) + with self.subTest(tool_profile=value): + self.assertNotIn("tool_profile", migrated) + self.assertEqual(warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertEqual(case["output"]["catalog"], "fixed") + self.assertEqual( + case["output"]["warning"], + LEGACY_TOOL_PROFILE_WARNING, + ) + + def test_phase04_oauth_store_reopens_after_idempotent_migration(self) -> None: + oauth = self.contract["oauth"] + self.assertEqual(oauth["persistent_store_phase"], 4) + self.assertEqual(oauth["migration"], "idempotent_transactional") + with oauth_root() as root: + path = root / "oauth.sqlite3" + first = OAuthAuthorizationStore(path, pepper=b"contract-pepper" * 2) + first.upsert_client( + "contract-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + workspace_id="contract-workspace", + ) + first.register_signing_key( + "contract-key", + "contract-fingerprint", + secret_ref="oauth-signing/contract-key", + ) + grant_id = first.create_grant("contract-agent", "mcp") + + second = OAuthAuthorizationStore(path, pepper=b"contract-pepper" * 2) + third = OAuthAuthorizationStore(path, pepper=b"contract-pepper" * 2) + self.assertEqual(second.get_client("contract-agent")["client_id"], "contract-agent") + self.assertEqual(third.get_grant(grant_id)["client_id"], "contract-agent") + with closing(sqlite3.connect(path)) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + + def test_phase07_gateway_snapshot_contract_is_machine_readable(self) -> None: + gateway = self.contract["gateway"] + self.assertEqual(gateway["phase"], 7) + self.assertEqual(gateway["namespace"], "{alias}__{remote_name}") + self.assertIs(gateway["local_names_reserved"], True) + self.assertEqual(gateway["collision"], "fail_closed") + self.assertEqual(gateway["snapshot_point"], "runtime_initialize") + self.assertIs(gateway["immutable_per_runtime"], True) + self.assertIs(gateway["list_changed"], False) + self.assertIs(gateway["config_before_initialize"], True) + self.assertEqual(gateway["schema"], "preserve_except_public_name") + self.assertEqual(gateway["annotations"], "preserve_real") + self.assertEqual(gateway["structured_content"], "preserve") + self.assertIs(gateway["remote_workspace_boundary_claim"], False) + self.assertIs(gateway["session_identity_mutation"], False) + self.assertIs(gateway["tool_profile_controls"], False) + self.assertFalse(hasattr(UpstreamManager, "start_server")) + self.assertFalse(hasattr(UpstreamManager, "stop_server")) + self.assertNotIn("tool_profile", inspect.getsource(upstream_module)) + + def test_phase06_http_session_binding_is_immutable_and_fails_closed(self) -> None: + binding = self.contract["workspace_binding"] + self.assertEqual(binding["phase"], 6) + self.assertEqual(binding["point"], "http_initialize_runtime_factory") + self.assertTrue(binding["immutable_per_session"]) + self.assertEqual(binding["invalid_mapping"], "fail_closed") + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + resolver = WorkspaceBindingResolver( + WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=True), + ], + "first", + ) + ) + identity = OAuthIdentity("agent", "grant", "second", "jti") + resolved = resolver.resolve_http("oauth", identity) + self.assertEqual(resolved.workspace_id, "second") + resolver.update_catalog( + WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + ) + self.assertEqual(resolved.workspace_id, "second") + with self.assertRaises(WorkspaceBindingError): + resolver.resolve_http("oauth", identity) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_fail_closed.py b/tests/test_oauth_fail_closed.py new file mode 100644 index 0000000..93ed242 --- /dev/null +++ b/tests/test_oauth_fail_closed.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +import os +import shutil +import sqlite3 +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +from coding_tools_mcp.oauth_store import OAuthStoreError +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + build_persistent_oauth_config, +) + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class OAuthPersistenceFailClosedTests(unittest.TestCase): + def test_dcr_authorize_and_token_reject_store_failure_without_fallback(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="fail-closed-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + try: + registration = urllib.request.Request( + f"{base}/oauth/register", + data=json.dumps( + { + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with patch.object( + config.store, + "list_clients", + side_effect=OAuthStoreError("synthetic store failure"), + ): + with self.assertRaises(urllib.error.HTTPError) as dcr_error: + urllib.request.urlopen(registration, timeout=5) + self.assertEqual(dcr_error.exception.code, 503) + self.assertEqual(json.loads(dcr_error.exception.read())["error"], "server_error") + + query = urllib.parse.urlencode( + { + "response_type": "code", + "client_id": "fail-closed-agent", + "redirect_uri": "http://127.0.0.1/callback", + "code_challenge": "A" * 43, + "code_challenge_method": "S256", + "resource": base, + } + ) + with patch.object( + config.store, + "get_client", + side_effect=OAuthStoreError("synthetic store failure"), + ): + with self.assertRaises(urllib.error.HTTPError) as authorize_error: + urllib.request.urlopen(f"{base}/oauth/authorize?{query}", timeout=5) + self.assertEqual(authorize_error.exception.code, 503) + + token_request = urllib.request.Request( + f"{base}/oauth/token", + data=urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "client_id": "fail-closed-agent", + "code": "not-reached", + "redirect_uri": "http://127.0.0.1/callback", + "code_verifier": "v" * 43, + "resource": base, + } + ).encode("ascii"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with patch.object( + config.store, + "get_client", + side_effect=OAuthStoreError("synthetic store failure"), + ): + with self.assertRaises(urllib.error.HTTPError) as token_error: + urllib.request.urlopen(token_request, timeout=5) + self.assertEqual(token_error.exception.code, 503) + self.assertEqual(json.loads(token_error.exception.read())["error"], "server_error") + self.assertEqual( + [item["client_id"] for item in config.store.list_clients()], + ["fail-closed-agent"], + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_integration.py b/tests/test_oauth_integration.py new file mode 100644 index 0000000..3dfb921 --- /dev/null +++ b/tests/test_oauth_integration.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import shutil +import tempfile +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +import jwt +import sqlite3 +import unittest +from contextlib import closing, contextmanager, redirect_stderr +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +from coding_tools_mcp.oauth import ( + PersistentOAuthClientRegistry, + authenticate_access_token, + create_access_token, + create_authorization_grant, + oauth_signing_kid, + validate_access_token, +) +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore, OAuthStoreError +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + build_persistent_oauth_config, +) + + +PEPPER = b"phase-05-registry-pepper" * 2 + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class PersistentOAuthClientRegistryTests(unittest.TestCase): + def test_preregistered_clients_reopen_with_exact_redirect_and_auth_method(self) -> None: + with oauth_root() as root: + path = root / "oauth.sqlite3" + registry = PersistentOAuthClientRegistry( + OAuthAuthorizationStore(path, pepper=PEPPER) + ) + registry.add_preregistered( + "public-agent", + ("http://127.0.0.1/callback",), + client_secret=None, + ) + registry.add_preregistered( + "confidential-agent", + ("https://agent.example/callback",), + client_secret="synthetic-client-secret", + ) + + reopened = PersistentOAuthClientRegistry( + OAuthAuthorizationStore(path, pepper=PEPPER) + ) + self.assertTrue( + reopened.accepts_redirect( + "public-agent", "http://127.0.0.1/callback" + ) + ) + self.assertFalse( + reopened.accepts_redirect( + "public-agent", "http://127.0.0.1/other" + ) + ) + self.assertTrue(reopened.authenticates("public-agent", "", "none")) + self.assertTrue( + reopened.authenticates( + "confidential-agent", + "synthetic-client-secret", + "client_secret_post", + ) + ) + self.assertFalse( + reopened.authenticates( + "confidential-agent", "wrong", "client_secret_post" + ) + ) + record = reopened.store.get_client("confidential-agent") + self.assertEqual( + record["client_secret_digest"], + hashlib.sha256(b"synthetic-client-secret").hexdigest(), + ) + self.assertNotIn("synthetic-client-secret", str(record)) + + +class PersistentAccessTokenTests(unittest.TestCase): + def test_jti_kid_and_revocation_state_are_enforced(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url="https://mcp.example", + token_ttl=86_400, + client_id="token-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="token-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + kid = oauth_signing_kid(config) + config.store.register_signing_key( + kid, + hashlib.sha256(config.token_secret).hexdigest(), + secret_ref="oauth/token-secret", + ) + token = create_access_token( + config, + "https://mcp.example", + client_id="token-agent", + grant_id=grant_id, + ) + header = jwt.get_unverified_header(token) + claims = jwt.decode( + token, + config.token_secret, + algorithms=["HS256"], + audience="https://mcp.example", + issuer="https://mcp.example", + ) + self.assertEqual(header["kid"], kid) + self.assertEqual(claims["client_id"], "token-agent") + self.assertEqual(claims["grant_id"], grant_id) + self.assertEqual(claims["sub"], grant_id) + self.assertTrue(claims["jti"]) + persisted = config.store.list_access_tokens("token-agent") + self.assertEqual(persisted[0]["jti"], claims["jti"]) + self.assertNotIn(token, str(persisted)) + identity = authenticate_access_token(token, config, "https://mcp.example") + self.assertIsNotNone(identity) + self.assertEqual(identity.client_id, "token-agent") + self.assertEqual(identity.grant_id, grant_id) + self.assertEqual(identity.workspace_id, "default") + self.assertEqual(identity.jti, claims["jti"]) + self.assertTrue(validate_access_token(token, config, "https://mcp.example")) + + config.store.revoke_access_token(claims["jti"], reason="test") + self.assertFalse(validate_access_token(token, config, "https://mcp.example")) + + second_grant = create_authorization_grant( + config, + client_id="token-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + second = create_access_token( + config, + "https://mcp.example", + client_id="token-agent", + grant_id=second_grant, + ) + self.assertTrue(validate_access_token(second, config, "https://mcp.example")) + config.store.revoke_grant(second_grant, reason="test") + self.assertFalse(validate_access_token(second, config, "https://mcp.example")) + + third_grant = create_authorization_grant( + config, + client_id="token-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + third = create_access_token( + config, + "https://mcp.example", + client_id="token-agent", + grant_id=third_grant, + ) + self.assertTrue(validate_access_token(third, config, "https://mcp.example")) + config.store.set_client_enabled("token-agent", False, reason="test") + self.assertFalse(validate_access_token(third, config, "https://mcp.example")) + + +class BearerFailClosedTests(unittest.TestCase): + def test_store_failure_denies_bearer_without_logging_token(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="bearer-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="bearer-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + kid = oauth_signing_kid(config) + config.store.register_signing_key( + kid, + hashlib.sha256(config.token_secret).hexdigest(), + secret_ref="oauth/token-secret", + ) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + token = create_access_token( + config, + base, + client_id="bearer-agent", + grant_id=grant_id, + ) + + def ping() -> int: + request = urllib.request.Request( + f"{base}/mcp", + data=b'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}', + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + }, + method="POST", + ) + return urllib.request.urlopen(request, timeout=5).status + + try: + self.assertEqual(ping(), 200) + captured = io.StringIO() + with patch.object( + config.store, + "active_access_token_identity", + side_effect=OAuthStoreError("synthetic database failure"), + ), redirect_stderr(captured): + with self.assertRaises(urllib.error.HTTPError) as caught: + ping() + self.assertEqual(caught.exception.code, 401) + self.assertIn("validation unavailable", captured.getvalue()) + self.assertNotIn(token, captured.getvalue()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +class PersistentOAuthCompositionTests(unittest.TestCase): + def test_dcr_client_persists_across_runtime_rebuild_with_supported_grants(self) -> None: + with oauth_root() as root: + config, created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + self.assertFalse(created) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + body = json.dumps( + { + "client_name": "Persistent DCR Agent", + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + ).encode("utf-8") + request = urllib.request.Request( + f"{base}/oauth/register", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + registered = json.loads(response.read()) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + self.assertEqual( + registered["grant_types"], + ["authorization_code", "refresh_token"], + ) + self.assertEqual(registered["response_types"], ["code"]) + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + stored = reopened.registry.get(str(registered["client_id"])) + self.assertIsNotNone(stored) + self.assertEqual(stored.client_name, "Persistent DCR Agent") + self.assertEqual( + stored.redirect_uris, + ("http://127.0.0.1/callback",), + ) + + def test_authorization_approval_persists_grant_before_issuing_code(self) -> None: + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="grant-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_address[1]}" + body = urllib.parse.urlencode( + { + "client_id": "grant-agent", + "redirect_uri": "http://127.0.0.1/callback", + "code_challenge": "A" * 43, + "code_challenge_method": "S256", + "state": "state-a", + "resource": base, + "password": "synthetic-authorize-password", + } + ).encode("ascii") + request = urllib.request.Request( + f"{base}/oauth/authorize", + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + opener = urllib.request.build_opener(NoRedirect) + try: + with self.assertRaises(urllib.error.HTTPError) as caught: + opener.open(request, timeout=5) + self.assertEqual(caught.exception.code, 302) + self.assertIn("code=", caught.exception.headers["Location"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + grants = config.store.list_grants("grant-agent") + self.assertEqual(len(grants), 1) + self.assertEqual(grants[0]["scopes"], "mcp") + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + client_id="grant-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + self.assertEqual(reopened.store.list_grants("grant-agent"), grants) + + def test_persistent_oauth_config_requires_secret_vault_key(self) -> None: + with oauth_root() as root: + with self.assertRaisesRegex(ValueError, "SECRETS_KEY"): + build_persistent_oauth_config( + root, + master_key=None, + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_refresh.py b/tests/test_oauth_refresh.py new file mode 100644 index 0000000..8d3bd1e --- /dev/null +++ b/tests/test_oauth_refresh.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import shutil +import sqlite3 +import tempfile +import threading +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator +from unittest.mock import patch + +from coding_tools_mcp.oauth_store import OAuthAuthorizationStore +from coding_tools_mcp.server import ( + MCPHandler, + Runtime, + RuntimeHTTPServer, + build_persistent_oauth_config, +) + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +def start_server(root: Path, config): # type: ignore[no-untyped-def] + runtime = Runtime(root, oauth_config=config, transport="http") + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + runtime, + lambda: Runtime(root, oauth_config=config, transport="http"), + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server, thread, f"http://127.0.0.1:{server.server_address[1]}" + + +def stop_server(server: RuntimeHTTPServer, thread: threading.Thread) -> None: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def post_form(base: str, path: str, payload: dict[str, str]) -> tuple[int, dict[str, object]]: + request = urllib.request.Request( + f"{base}{path}", + data=urllib.parse.urlencode(payload).encode("ascii"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +class RefreshTokenHTTPTests(unittest.TestCase): + def test_rotation_survives_rebuild_and_reuse_revokes_family(self) -> None: + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] + return None + + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + server, thread, base = start_server(root, config) + verifier = "r" * 43 + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode("ascii")).digest() + ).rstrip(b"=").decode("ascii") + registration = urllib.request.Request( + f"{base}/oauth/register", + data=json.dumps( + { + "client_name": "Refresh Agent", + "redirect_uris": ["http://127.0.0.1/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + } + ).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + metadata = json.loads( + urllib.request.urlopen( + f"{base}/.well-known/oauth-authorization-server", + timeout=5, + ).read() + ) + self.assertEqual( + metadata["grant_types_supported"], + ["authorization_code", "refresh_token"], + ) + registered = json.loads( + urllib.request.urlopen(registration, timeout=5).read() + ) + self.assertEqual( + registered["grant_types"], + ["authorization_code", "refresh_token"], + ) + authorize = urllib.request.Request( + f"{base}/oauth/authorize", + data=urllib.parse.urlencode( + { + "client_id": str(registered["client_id"]), + "redirect_uri": "http://127.0.0.1/callback", + "code_challenge": challenge, + "code_challenge_method": "S256", + "state": "state-refresh", + "resource": base, + "password": "synthetic-authorize-password", + } + ).encode("ascii"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + opener = urllib.request.build_opener(NoRedirect) + with self.assertRaises(urllib.error.HTTPError) as redirected: + opener.open(authorize, timeout=5) + self.assertEqual(redirected.exception.code, 302) + location = redirected.exception.headers["Location"] + code = urllib.parse.parse_qs( + urllib.parse.urlparse(location).query + )["code"][0] + token_status, issued = post_form( + base, + "/oauth/token", + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://127.0.0.1/callback", + "code_verifier": verifier, + "client_id": str(registered["client_id"]), + "resource": base, + }, + ) + self.assertEqual(token_status, 200) + self.assertTrue(issued["access_token"]) + original_refresh = str(issued["refresh_token"]) + finally: + stop_server(server, thread) + + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=None, + token_ttl=86_400, + ) + restarted, restarted_thread, restarted_base = start_server(root, reopened) + try: + original_audit = OAuthAuthorizationStore._audit + + def fail_access_audit( + conn: sqlite3.Connection, + event_type: str, + **kwargs: object, + ) -> None: + if event_type == "access_token_issued": + raise sqlite3.IntegrityError("injected access-token audit failure") + original_audit(conn, event_type, **kwargs) # type: ignore[arg-type] + + with patch.object( + OAuthAuthorizationStore, + "_audit", + staticmethod(fail_access_audit), + ): + failed_status, failed = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": original_refresh, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(failed_status, 503) + self.assertEqual(failed["error"], "server_error") + + refresh_status, refreshed = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": original_refresh, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(refresh_status, 200) + replacement = str(refreshed["refresh_token"]) + self.assertNotEqual(replacement, original_refresh) + self.assertTrue(refreshed["access_token"]) + + replay_status, replay = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": original_refresh, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(replay_status, 400) + self.assertEqual(replay["error"], "invalid_grant") + + replacement_status, replacement_error = post_form( + restarted_base, + "/oauth/token", + { + "grant_type": "refresh_token", + "refresh_token": replacement, + "client_id": str(registered["client_id"]), + }, + ) + self.assertEqual(replacement_status, 400) + self.assertEqual(replacement_error["error"], "invalid_grant") + families = reopened.store.list_refresh_token_families( + str(registered["client_id"]) + ) + self.assertEqual(len(families), 1) + self.assertIsNotNone(families[0]["revoked_at"]) + self.assertEqual(families[0]["revoke_reason"], "refresh_token_reuse") + finally: + stop_server(restarted, restarted_thread) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_signing.py b/tests/test_oauth_signing.py new file mode 100644 index 0000000..37f4d45 --- /dev/null +++ b/tests/test_oauth_signing.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import os +import shutil +import sqlite3 +import tempfile +import time +import unittest +from contextlib import closing, contextmanager +from pathlib import Path +from typing import Iterator + +from coding_tools_mcp.oauth import ( + create_access_token, + create_authorization_grant, + revoke_signing_key, + rotate_signing_key, + validate_access_token, +) +from coding_tools_mcp.server import build_persistent_oauth_config + + +ISSUER = "https://mcp.example" + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +class SigningKeyLifecycleTests(unittest.TestCase): + def test_rotation_reopen_and_emergency_revoke_preserve_key_boundaries(self) -> None: + with oauth_root() as root: + config, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="signing-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + grant_id = create_authorization_grant( + config, + client_id="signing-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + old_kid = str(config.signing_kid) + old_secret = config.signing_keys[old_kid] + old_token = create_access_token( + config, + ISSUER, + client_id="signing-agent", + grant_id=grant_id, + ) + + rotated = rotate_signing_key(config) + new_kid = str(rotated.signing_kid) + new_secret = rotated.signing_keys[new_kid] + self.assertNotEqual(new_kid, old_kid) + new_token = create_access_token( + rotated, + ISSUER, + client_id="signing-agent", + grant_id=grant_id, + ) + self.assertTrue(validate_access_token(old_token, rotated, ISSUER)) + self.assertTrue(validate_access_token(new_token, rotated, ISSUER)) + states = { + item["kid"]: item["status"] + for item in rotated.store.list_signing_keys() + } + self.assertEqual(states[old_kid], "retired") + self.assertEqual(states[new_kid], "active") + + reopened, _created = build_persistent_oauth_config( + root, + master_key="synthetic-master-key", + password="synthetic-authorize-password", + server_url=ISSUER, + token_ttl=86_400, + client_id="signing-agent", + redirect_uris=("http://127.0.0.1/callback",), + ) + self.assertEqual(reopened.signing_kid, new_kid) + self.assertEqual(set(reopened.signing_keys), {old_kid, new_kid}) + self.assertTrue(validate_access_token(old_token, reopened, ISSUER)) + self.assertTrue(validate_access_token(new_token, reopened, ISSUER)) + + self.assertTrue(revoke_signing_key(reopened, old_kid)) + self.assertFalse(validate_access_token(old_token, reopened, ISSUER)) + self.assertTrue(validate_access_token(new_token, reopened, ISSUER)) + refs = { + item["kid"]: item["secret_ref"] + for item in reopened.store.list_signing_keys() + } + self.assertTrue(refs[old_kid].startswith("oauth/signing/")) + self.assertTrue(refs[new_kid].startswith("oauth/signing/")) + self.assertNotIn(old_secret.hex(), str(reopened.store.list_signing_keys())) + self.assertNotIn(new_secret.hex(), str(reopened.store.list_signing_keys())) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + database_bytes = (root / "oauth.sqlite3").read_bytes() + self.assertNotIn(old_secret, database_bytes) + self.assertNotIn(new_secret, database_bytes) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_oauth_store.py b/tests/test_oauth_store.py new file mode 100644 index 0000000..5e049ba --- /dev/null +++ b/tests/test_oauth_store.py @@ -0,0 +1,639 @@ +from __future__ import annotations + +import hashlib +import hmac +import os +import shutil +import sqlite3 +import tempfile +import time +import threading +import unittest +from collections.abc import Iterator +from contextlib import closing, contextmanager +from pathlib import Path +from unittest.mock import patch + +from coding_tools_mcp.oauth_store import ( + OAuthAuthorizationStore, + OAuthStoreError, + RefreshTokenClientMismatchError, + RefreshTokenResult, +) + + +PEPPER = b"phase-04-test-pepper" * 2 +FUTURE = 4_000_000_000.0 + + +@contextmanager +def oauth_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +def prepared_store( + root: Path, + *, + store_type: type[OAuthAuthorizationStore] = OAuthAuthorizationStore, +) -> tuple[OAuthAuthorizationStore, str]: + store = store_type(root / "oauth.sqlite3", pepper=PEPPER) + store.upsert_client( + "agent-a", + display_name="Agent A", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + workspace_id="workspace-a", + ) + store.register_signing_key( + "key-a", + "fingerprint-a", + secret_ref="oauth-signing/key-a", + ) + grant_id = store.create_grant("agent-a", "mcp") + return store, grant_id + + +class OAuthStoreTests(unittest.TestCase): + def test_schema_contains_every_required_metadata_table_and_reopens(self) -> None: + expected = { + "oauth_clients", + "oauth_grants", + "oauth_access_tokens", + "oauth_refresh_token_families", + "oauth_refresh_tokens", + "oauth_signing_keys", + "oauth_audit_events", + } + with oauth_root() as root: + store, grant_id = prepared_store(root) + store.record_access_token( + "jti-a", + grant_id, + "agent-a", + "key-a", + "mcp", + issued_at=1, + expires_at=FUTURE, + ) + family_id, _token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + + reopened = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + self.assertEqual(reopened.get_client("agent-a")["display_name"], "Agent A") + self.assertEqual(reopened.get_grant(grant_id)["client_id"], "agent-a") + self.assertEqual(reopened.list_access_tokens()[0]["jti"], "jti-a") + self.assertEqual( + reopened.list_refresh_token_families()[0]["family_id"], + family_id, + ) + self.assertEqual(reopened.list_signing_keys()[0]["secret_ref"], "oauth-signing/key-a") + self.assertTrue(reopened.list_audit_events()) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'oauth_%'" + ) + } + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertEqual(tables, expected) + self.assertEqual(version, OAuthAuthorizationStore.SCHEMA_VERSION) + + def test_v1_migration_is_repeatable_and_preserves_existing_rows(self) -> None: + with oauth_root() as root: + path = root / "oauth.sqlite3" + with closing(sqlite3.connect(path, isolation_level=None)) as conn: + conn.execute("BEGIN") + for statement in OAuthAuthorizationStore._schema_v1_statements(): + conn.execute(statement) + conn.execute( + """ + INSERT INTO oauth_clients( + client_id, display_name, redirect_uri, allowed_scopes, + created_at, updated_at + ) VALUES('legacy-agent','Legacy Agent','http://127.0.0.1/callback','mcp',1,1) + """ + ) + conn.execute( + """ + INSERT INTO oauth_signing_keys( + kid, algorithm, fingerprint, status, created_at, activated_at + ) VALUES('legacy-key','HS256','legacy-fingerprint','active',1,1) + """ + ) + conn.execute("PRAGMA user_version = 1") + conn.commit() + + first = OAuthAuthorizationStore(path, pepper=PEPPER) + second = OAuthAuthorizationStore(path, pepper=PEPPER) + self.assertEqual(first.list_signing_keys()[0]["kid"], "legacy-key") + self.assertIsNone(first.list_signing_keys()[0]["secret_ref"]) + self.assertEqual(second.list_signing_keys(), first.list_signing_keys()) + legacy_client = first.get_client("legacy-agent") + self.assertEqual( + legacy_client["redirect_uris"], + ["http://127.0.0.1/callback"], + ) + self.assertEqual(legacy_client["token_endpoint_auth_method"], "none") + self.assertIsNone(legacy_client["client_secret_digest"]) + with closing(sqlite3.connect(path)) as conn: + key_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_signing_keys)") + } + client_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_clients)") + } + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertIn("secret_ref", key_columns) + self.assertIn("redirect_uris_json", client_columns) + self.assertIn("token_endpoint_auth_method", client_columns) + self.assertIn("client_secret_digest", client_columns) + self.assertEqual(version, OAuthAuthorizationStore.SCHEMA_VERSION) + + def test_workspace_binding_migration_is_explicit_and_grants_freeze_it(self) -> None: + with oauth_root() as root: + path = root / "oauth.sqlite3" + store = OAuthAuthorizationStore(path, pepper=PEPPER) + store.upsert_client( + "workspace-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + with self.assertRaisesRegex(OAuthStoreError, "no authorized Workspace binding"): + store.create_grant("workspace-agent", "mcp") + self.assertTrue(store.set_client_workspace("workspace-agent", "workspace-a")) + grant_id = store.create_grant("workspace-agent", "mcp") + self.assertEqual(store.get_client("workspace-agent")["workspace_id"], "workspace-a") + self.assertEqual(store.get_grant(grant_id)["workspace_id"], "workspace-a") + + self.assertTrue(store.set_client_workspace("workspace-agent", "workspace-b")) + second_grant = store.create_grant("workspace-agent", "mcp") + self.assertEqual(store.get_grant(grant_id)["workspace_id"], "workspace-a") + self.assertEqual(store.get_grant(second_grant)["workspace_id"], "workspace-b") + with closing(sqlite3.connect(path)) as conn: + client_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_clients)") + } + grant_columns = { + row[1] for row in conn.execute("PRAGMA table_info(oauth_grants)") + } + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertIn("workspace_id", client_columns) + self.assertIn("workspace_id", grant_columns) + self.assertEqual(version, OAuthAuthorizationStore.SCHEMA_VERSION) + + def test_confidential_client_metadata_round_trips_without_plaintext_secret(self) -> None: + with oauth_root() as root: + store = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + raw_secret = "client-secret-must-not-be-stored" + digest = hashlib.sha256(raw_secret.encode()).hexdigest() + redirects = ( + "https://agent.example/callback", + "http://127.0.0.1/callback", + ) + store.upsert_client( + "confidential-agent", + display_name="Confidential Agent", + scopes="mcp", + redirect_uris=redirects, + client_type="confidential", + token_endpoint_auth_method="client_secret_basic", + client_secret_digest=digest, + ) + reopened = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + client = reopened.get_client("confidential-agent") + self.assertEqual(client["redirect_uris"], list(redirects)) + self.assertEqual( + client["token_endpoint_auth_method"], + "client_secret_basic", + ) + self.assertEqual(client["client_secret_digest"], digest) + self.assertNotIn(raw_secret, str(client)) + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + persisted = b"".join( + path.read_bytes() + for path in root.iterdir() + if path.name.startswith("oauth.sqlite3") + ) + self.assertNotIn(raw_secret.encode(), persisted) + + def test_failed_migration_rolls_back_all_schema_changes(self) -> None: + class BrokenMigrationStore(OAuthAuthorizationStore): + @classmethod + def _schema_v1_statements(cls) -> tuple[str, ...]: + valid = super()._schema_v1_statements() + return (valid[0], "CREATE TABLE broken syntax", *valid[1:]) + + with oauth_root() as root: + path = root / "oauth.sqlite3" + with self.assertRaises(OAuthStoreError): + BrokenMigrationStore(path, pepper=PEPPER) + with closing(sqlite3.connect(path)) as conn: + tables = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'oauth_%'" + ).fetchall() + version = conn.execute("PRAGMA user_version").fetchone()[0] + self.assertEqual(tables, []) + self.assertEqual(version, 0) + + def test_client_disable_and_grant_revoke_are_idempotent(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + self.assertTrue(store.revoke_grant(grant_id, reason="test")) + first_grant = store.get_grant(grant_id) + self.assertTrue(store.revoke_grant(grant_id, reason="different")) + second_grant = store.get_grant(grant_id) + self.assertEqual(first_grant["revoked_at"], second_grant["revoked_at"]) + self.assertEqual(second_grant["revoke_reason"], "test") + + self.assertTrue(store.set_client_enabled("agent-a", False, reason="test")) + first_client = store.get_client("agent-a") + self.assertTrue(store.set_client_enabled("agent-a", False, reason="different")) + second_client = store.get_client("agent-a") + self.assertEqual(first_client["revoked_at"], second_client["revoked_at"]) + self.assertFalse(bool(second_client["enabled"])) + self.assertFalse(store.set_client_enabled("missing", False)) + + def test_access_token_and_signing_key_lifecycle_fail_closed(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + store.record_access_token( + "jti-a", + grant_id, + "agent-a", + "key-a", + "mcp", + issued_at=1, + expires_at=FUTURE, + ) + self.assertTrue(store.access_token_is_active("jti-a", now=2)) + + store.register_signing_key( + "key-b", + "fingerprint-b", + secret_ref="oauth-signing/key-b", + ) + states = {item["kid"]: item["status"] for item in store.list_signing_keys()} + self.assertEqual(states, {"key-a": "retired", "key-b": "active"}) + self.assertTrue(store.access_token_is_active("jti-a", now=2)) + self.assertTrue(store.activate_signing_key("key-a")) + self.assertTrue(store.retire_signing_key("key-a")) + self.assertTrue(store.revoke_signing_key("key-a")) + first = next(item for item in store.list_signing_keys() if item["kid"] == "key-a") + self.assertTrue(store.revoke_signing_key("key-a")) + second = next(item for item in store.list_signing_keys() if item["kid"] == "key-a") + self.assertEqual(first["revoked_at"], second["revoked_at"]) + self.assertFalse(store.access_token_is_active("jti-a", now=2)) + + def test_access_token_revocation_is_idempotent(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + store.record_access_token( + "jti-a", + grant_id, + "agent-a", + "key-a", + "mcp", + issued_at=1, + expires_at=FUTURE, + ) + self.assertTrue(store.revoke_access_token("jti-a", reason="test")) + first = store.list_access_tokens()[0] + self.assertTrue(store.revoke_access_token("jti-a", reason="different")) + second = store.list_access_tokens()[0] + self.assertEqual(first["revoked_at"], second["revoked_at"]) + self.assertEqual(second["revoke_reason"], "test") + revoked_events = [ + item + for item in store.list_audit_events(limit=500) + if item["event_type"] == "access_token_revoked" + ] + self.assertEqual(len(revoked_events), 1) + + def test_refresh_token_is_stored_only_as_peppered_digest(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + expected = hmac.new(PEPPER, token.encode(), hashlib.sha256).hexdigest() + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + row = conn.execute( + "SELECT token_hash FROM oauth_refresh_tokens WHERE family_id=?", + (family_id,), + ).fetchone() + refresh_columns = { + item[1] for item in conn.execute("PRAGMA table_info(oauth_refresh_tokens)") + } + access_columns = { + item[1] for item in conn.execute("PRAGMA table_info(oauth_access_tokens)") + } + self.assertEqual(row[0], expected) + self.assertNotIn("token", refresh_columns) + self.assertNotIn("access_token", access_columns) + self.assertNotIn(token, str(store.list_audit_events(limit=500))) + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + + persisted = b"".join( + path.read_bytes() + for path in root.iterdir() + if path.name.startswith("oauth.sqlite3") + ) + self.assertNotIn(token.encode(), persisted) + self.assertNotIn("fingerprint-a".encode() + b"-secret-material", persisted) + + def test_refresh_rotation_detects_reuse_and_revokes_family(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + replacement = store.rotate_refresh_token(token, expires_at=FUTURE) + self.assertIsInstance(replacement, RefreshTokenResult) + self.assertFalse(store.refresh_family_is_revoked(family_id)) + + self.assertIsNone(store.rotate_refresh_token(token, expires_at=FUTURE)) + self.assertTrue(store.refresh_family_is_revoked(family_id)) + self.assertIsNone( + store.rotate_refresh_token(replacement.token, expires_at=FUTURE) + ) + reuse_events = [ + item + for item in store.list_audit_events(limit=500) + if item["event_type"] == "refresh_token_reuse" + ] + self.assertEqual(len(reuse_events), 1) + + def test_expired_refresh_token_is_rejected_even_when_family_is_valid(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + with patch("coding_tools_mcp.oauth_store.time.time", return_value=100.0): + _family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=200.0, + ) + replacement = store.rotate_refresh_token(token, expires_at=150.0) + self.assertIsNotNone(replacement) + + with patch("coding_tools_mcp.oauth_store.time.time", return_value=151.0): + self.assertIsNone( + store.rotate_refresh_token( + replacement.token, + expires_at=4_000_000_000.0, + ) + ) + + def test_refresh_family_revocation_is_idempotent(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, _token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + self.assertTrue(store.revoke_refresh_family(family_id, reason="test")) + first = store.list_refresh_token_families()[0] + self.assertTrue(store.revoke_refresh_family(family_id, reason="different")) + second = store.list_refresh_token_families()[0] + self.assertEqual(first["revoked_at"], second["revoked_at"]) + self.assertEqual(second["revoke_reason"], "test") + + def test_rotation_failure_rolls_back_replacement_and_old_token_state(self) -> None: + class FailingRotationStore(OAuthAuthorizationStore): + @staticmethod + def _audit( + conn: sqlite3.Connection, + event_type: str, + **kwargs: object, + ) -> None: + if event_type == "refresh_token_rotated": + raise sqlite3.IntegrityError("injected rotation audit failure") + OAuthAuthorizationStore._audit(conn, event_type, **kwargs) # type: ignore[arg-type] + + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + failing = FailingRotationStore(root / "oauth.sqlite3", pepper=PEPPER) + with self.assertRaises(OAuthStoreError): + failing.rotate_refresh_token(token, expires_at=FUTURE) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + rows = conn.execute( + """ + SELECT used_at, revoked_at, replacement_token_id + FROM oauth_refresh_tokens WHERE family_id=? + """, + (family_id,), + ).fetchall() + self.assertEqual(rows, [(None, None, None)]) + recovered = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + self.assertIsNotNone(recovered.rotate_refresh_token(token, expires_at=FUTURE)) + + def test_refresh_exchange_access_failure_rolls_back_rotation_and_metadata(self) -> None: + class FailingAccessAuditStore(OAuthAuthorizationStore): + @staticmethod + def _audit( + conn: sqlite3.Connection, + event_type: str, + **kwargs: object, + ) -> None: + if event_type == "access_token_issued": + raise sqlite3.IntegrityError("injected access-token audit failure") + OAuthAuthorizationStore._audit(conn, event_type, **kwargs) # type: ignore[arg-type] + + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + failing = FailingAccessAuditStore(root / "oauth.sqlite3", pepper=PEPPER) + binding = failing.refresh_token_binding(token) + self.assertIsNotNone(binding) + assert binding is not None + self.assertEqual(binding.client_id, "agent-a") + self.assertEqual(binding.grant_id, grant_id) + + with self.assertRaises(OAuthStoreError): + failing.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-a", + refresh_expires_at=FUTURE, + access_jti="jti-atomic-failure", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + refresh_rows = conn.execute( + """ + SELECT used_at, revoked_at, replacement_token_id + FROM oauth_refresh_tokens WHERE family_id=? + """, + (family_id,), + ).fetchall() + access_count = conn.execute( + "SELECT COUNT(*) FROM oauth_access_tokens WHERE jti=?", + ("jti-atomic-failure",), + ).fetchone()[0] + self.assertEqual(refresh_rows, [(None, None, None)]) + self.assertEqual(access_count, 0) + self.assertFalse( + any( + event["event_type"] in {"refresh_token_rotated", "access_token_issued"} + for event in failing.list_audit_events(limit=500) + ) + ) + + recovered = OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER) + rotated = recovered.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-a", + refresh_expires_at=FUTURE, + access_jti="jti-atomic-success", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + self.assertIsNotNone(rotated) + self.assertTrue(recovered.access_token_is_active("jti-atomic-success", now=101.0)) + + def test_refresh_exchange_client_mismatch_does_not_consume_token(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + + with self.assertRaises(RefreshTokenClientMismatchError): + store.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-b", + refresh_expires_at=FUTURE, + access_jti="jti-mismatch", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + + with closing(sqlite3.connect(root / "oauth.sqlite3")) as conn: + row = conn.execute( + """ + SELECT used_at, revoked_at, replacement_token_id + FROM oauth_refresh_tokens WHERE family_id=? + """, + (family_id,), + ).fetchone() + access_count = conn.execute( + "SELECT COUNT(*) FROM oauth_access_tokens WHERE jti=?", + ("jti-mismatch",), + ).fetchone()[0] + self.assertEqual(row, (None, None, None)) + self.assertEqual(access_count, 0) + + rotated = store.rotate_refresh_token_and_record_access_token( + token, + expected_client_id="agent-a", + refresh_expires_at=FUTURE, + access_jti="jti-after-mismatch", + access_signing_kid="key-a", + access_scopes="mcp", + access_issued_at=100.0, + access_expires_at=FUTURE, + ) + self.assertIsNotNone(rotated) + self.assertTrue(store.access_token_is_active("jti-after-mismatch", now=101.0)) + + def test_concurrent_refresh_rotation_has_one_winner_and_detects_reuse(self) -> None: + with oauth_root() as root: + store, grant_id = prepared_store(root) + family_id, token = store.issue_refresh_token( + grant_id, + "agent-a", + "mcp", + expires_at=FUTURE, + ) + stores = [ + OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER), + OAuthAuthorizationStore(root / "oauth.sqlite3", pepper=PEPPER), + ] + barrier = threading.Barrier(2) + results: list[RefreshTokenResult | None] = [] + errors: list[BaseException] = [] + + def rotate(candidate: OAuthAuthorizationStore) -> None: + try: + barrier.wait(timeout=5) + results.append(candidate.rotate_refresh_token(token, expires_at=FUTURE)) + except BaseException as exc: # pragma: no cover - reported below + errors.append(exc) + + threads = [threading.Thread(target=rotate, args=(candidate,)) for candidate in stores] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=10) + + self.assertEqual(errors, []) + self.assertEqual(sum(item is not None for item in results), 1) + self.assertEqual(sum(item is None for item in results), 1) + self.assertTrue(store.refresh_family_is_revoked(family_id)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_phase11_packaging.py b/tests/test_phase11_packaging.py new file mode 100644 index 0000000..0644231 --- /dev/null +++ b/tests/test_phase11_packaging.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pathlib import Path +import tomllib +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class Phase11PackagingTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + def test_webui_and_desktop_package_data_are_both_included(self) -> None: + package_data = self.pyproject["tool"]["setuptools"]["package-data"] + self.assertEqual(package_data["coding_tools_mcp"], ["webui_dist/*"]) + self.assertEqual( + package_data["mcp_desktop_client"], + ["locales/*.qm", "locales/*.ts"], + ) + self.assertTrue((ROOT / "coding_tools_mcp" / "webui_dist" / "admin.html").is_file()) + + def test_upstream_desktop_discovery_and_entrypoint_are_preserved(self) -> None: + project = self.pyproject["project"] + discovery = self.pyproject["tool"]["setuptools"]["packages"]["find"] + self.assertIn("desktop", project["optional-dependencies"]) + self.assertEqual( + project["scripts"]["coding-tools-mcp-desktop"], + "mcp_desktop_client.app:main", + ) + self.assertEqual(discovery["where"], [".", "apps/desktop-client"]) + self.assertEqual(discovery["include"], ["coding_tools_mcp*", "mcp_desktop_client*"]) + + def test_upstream_dev_and_image_extras_are_preserved(self) -> None: + extras = self.pyproject["project"]["optional-dependencies"] + self.assertIn("dev", extras) + self.assertIn("image", extras) + self.assertIn("mypy>=2.1,<2.2", extras["dev"]) + self.assertIn("Pillow>=10.0", extras["image"]) + + def test_compliance_ci_allows_setup_node_toolchain_under_landlock(self) -> None: + workflow = (ROOT / ".github" / "workflows" / "compliance.yml").read_text( + encoding="utf-8" + ) + setup_node = workflow.index("uses: actions/setup-node@v6") + allow_root = workflow.index("CODING_TOOLS_MCP_EXEC_ALLOW_ROOTS") + unit_discovery = workflow.index("name: Run unit discovery") + self.assertLess(setup_node, allow_root) + self.assertLess(allow_root, unit_discovery) + self.assertIn('readlink -f "$(command -v node)"', workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_checks.py b/tests/test_release_checks.py index b0e3019..96f96a2 100644 --- a/tests/test_release_checks.py +++ b/tests/test_release_checks.py @@ -10,6 +10,9 @@ from scripts.check_release_versions import validate_release +ROOT = Path(__file__).resolve().parents[1] + + class ReleaseMetadataTests(unittest.TestCase): def _write_release_tree( self, @@ -56,6 +59,10 @@ def test_release_metadata_rejects_prerelease_npm_version(self) -> None: with self.assertRaisesRegex(SystemExit, "not stable"): validate_release(root, "v0.2.0") + def test_current_integration_tree_requires_release_preparation(self) -> None: + with self.assertRaisesRegex(SystemExit, "Unreleased"): + validate_release(ROOT, "v0.2.2") + class FinalAuditTests(unittest.TestCase): def test_workflow_runs_url_filters_by_release_sha(self) -> None: diff --git a/tests/test_settings_foundation.py b/tests/test_settings_foundation.py new file mode 100644 index 0000000..039bfcf --- /dev/null +++ b/tests/test_settings_foundation.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import json +import os +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from coding_tools_mcp import secret_vault as secret_vault_module +from coding_tools_mcp import settings_store as settings_store_module +from coding_tools_mcp.secret_vault import SecretVault, SecretVaultError +from coding_tools_mcp.settings_definition import ( + LEGACY_TOOL_PROFILE_WARNING, + SettingsValidationError, + normalize_startup_settings_with_warnings, + pending_restart_fields, + schema_payload, +) +from coding_tools_mcp.settings_store import ( + SETTINGS_SCHEMA_VERSION, + ServerSettingsStore, + SettingsStoreError, + default_settings_dir, + sanitize_settings, +) +from coding_tools_mcp.workspace_catalog import ( + WorkspaceCatalog, + WorkspaceCatalogError, + WorkspaceEntry, +) + + +class SettingsStoreTests(unittest.TestCase): + def test_default_settings_dir_honors_package_config_override(self) -> None: + with TemporaryDirectory() as tmp: + configured = Path(tmp) / "configured" + with patch.dict( + os.environ, + {"CODING_TOOLS_MCP_CONFIG_DIR": str(configured)}, + clear=False, + ): + self.assertEqual(default_settings_dir(), configured) + + def test_settings_round_trip_and_schema_version(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + store = ServerSettingsStore(path) + warnings = store.write( + { + "host": "127.0.0.1", + "port": 8765, + "oauth_active_key_secret_ref": "oauth-signing/key-a", + } + ) + + self.assertEqual(warnings, ()) + persisted = store.read() + self.assertEqual(persisted["schema_version"], SETTINGS_SCHEMA_VERSION) + self.assertEqual(persisted["port"], 8765) + self.assertEqual( + persisted["oauth_active_key_secret_ref"], + "oauth-signing/key-a", + ) + + def test_legacy_schema_and_tool_profile_are_migrated_in_memory(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + path.write_text( + json.dumps( + { + "schema_version": 0, + "host": "127.0.0.1", + "tool_profile": "read-only", + } + ), + encoding="utf-8", + ) + + result = ServerSettingsStore(path).read_result() + + self.assertTrue(result.migrated) + self.assertEqual(result.warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertNotIn("tool_profile", result.settings) + self.assertEqual(result.settings["schema_version"], SETTINGS_SCHEMA_VERSION) + + def test_next_successful_write_omits_legacy_tool_profile(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + store = ServerSettingsStore(path) + + warnings = store.write({"host": "localhost", "tool_profile": "unknown-profile"}) + raw = json.loads(path.read_text(encoding="utf-8")) + + self.assertEqual(warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertNotIn("tool_profile", raw) + + def test_atomic_replace_failure_preserves_previous_settings(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "server-settings.json" + store = ServerSettingsStore(path) + store.write({"port": 8000}) + original = path.read_bytes() + + with patch.object( + settings_store_module.os, + "replace", + side_effect=OSError("replace failed"), + ): + with self.assertRaises(SettingsStoreError): + store.write({"port": 9000}) + + self.assertEqual(path.read_bytes(), original) + self.assertEqual(store.read()["port"], 8000) + self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), []) + + def test_plaintext_secrets_are_rejected_and_sanitized(self) -> None: + settings = { + "oauth_token_secret": "secret-canary", + "oauth_active_key_secret_ref": "oauth-signing/key-a", + } + sanitized = sanitize_settings(settings) + + self.assertNotIn("secret-canary", repr(sanitized)) + self.assertTrue(sanitized["oauth_token_secret_configured"]) + self.assertEqual( + sanitized["oauth_active_key_secret_ref"], + {"configured": True}, + ) + with TemporaryDirectory() as tmp: + with self.assertRaises(SettingsStoreError): + ServerSettingsStore(Path(tmp) / "server-settings.json").write(settings) + + +class SettingsDefinitionTests(unittest.TestCase): + def test_normalization_ignores_tool_profile_and_reports_warning(self) -> None: + with TemporaryDirectory() as tmp: + workspace = Path(tmp) + normalized, warnings = normalize_startup_settings_with_warnings( + {"tool_profile": "compat-readonly-all", "permission_mode": "safe"}, + { + "tool_profile": "read-only", + "host": "LOCALHOST", + "port": "8765", + "workspace": str(workspace), + }, + workspace, + ) + + self.assertEqual(warnings, (LEGACY_TOOL_PROFILE_WARNING,)) + self.assertNotIn("tool_profile", normalized) + self.assertEqual(normalized["host"], "localhost") + self.assertEqual(normalized["port"], 8765) + self.assertNotIn("tool_profile", schema_payload()) + self.assertNotIn("tool_profile", schema_payload()["restart_fields"]) + + def test_oauth_client_workspace_bindings_require_enabled_catalog_entries(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + updates = { + "workspace_catalog": [ + { + "id": "first", + "name": "First", + "root": str(first), + "enabled": True, + "default": True, + }, + { + "id": "second", + "name": "Second", + "root": str(second), + "enabled": False, + "default": False, + }, + ], + "default_workspace_id": "first", + "oauth_client_workspace_bindings": {"agent-a": "first"}, + } + normalized, _warnings = normalize_startup_settings_with_warnings( + {}, updates, first + ) + self.assertEqual( + normalized["oauth_client_workspace_bindings"], + {"agent-a": "first"}, + ) + self.assertIn( + "oauth_client_workspace_bindings", + schema_payload()["restart_fields"], + ) + + updates["oauth_client_workspace_bindings"] = {"agent-a": "second"} + with self.assertRaisesRegex(SettingsValidationError, "unknown or disabled"): + normalize_startup_settings_with_warnings({}, updates, first) + + def test_pending_restart_fields_compares_only_active_settings(self) -> None: + active = {"port": 8000, "permission_mode": "safe", "tool_profile": "read-only"} + persisted = {"port": 9000, "permission_mode": "safe"} + + self.assertEqual(pending_restart_fields(active, persisted), ["port"]) + + +class WorkspaceCatalogTests(unittest.TestCase): + def test_catalog_canonicalizes_one_default_and_hides_disabled_entries(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("first", " First ", first), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + + self.assertTrue(catalog.default().default) + self.assertEqual(catalog.default().name, "First") + self.assertEqual([item.id for item in catalog.enabled_entries()], ["first"]) + self.assertEqual([item["id"] for item in catalog.payload()["workspaces"]], ["first"]) + with self.assertRaises(WorkspaceCatalogError): + catalog.get("second") + + def test_duplicate_ids_and_multiple_defaults_are_rejected(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + with self.assertRaisesRegex(WorkspaceCatalogError, "IDs must be unique"): + WorkspaceCatalog( + [ + WorkspaceEntry("same", "First", first), + WorkspaceEntry("same", "Second", second), + ], + "same", + ) + with self.assertRaisesRegex(WorkspaceCatalogError, "only one default"): + WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, default=True), + WorkspaceEntry("second", "Second", second, default=True), + ], + "first", + ) + + def test_disabled_default_nested_roots_and_missing_paths_are_rejected(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + parent = root / "parent" + child = parent / "child" + parent.mkdir() + child.mkdir() + with self.assertRaisesRegex(WorkspaceCatalogError, "default workspace must be enabled"): + WorkspaceCatalog( + [WorkspaceEntry("parent", "Parent", parent, enabled=False)], + "parent", + ) + with self.assertRaisesRegex(WorkspaceCatalogError, "Nested workspace roots"): + WorkspaceCatalog( + [ + WorkspaceEntry("parent", "Parent", parent), + WorkspaceEntry("child", "Child", child), + ], + "parent", + ) + with self.assertRaisesRegex(WorkspaceCatalogError, "cannot be resolved"): + WorkspaceCatalog.single(root / "missing") + + +class SecretVaultTests(unittest.TestCase): + def test_round_trip_is_encrypted_and_wrong_key_fails(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "oauth-secrets.json" + vault = SecretVault(path, "test-master-key") + vault.set_secret("oauth-signing/key-a", "secret-canary-value") + + raw = path.read_text(encoding="utf-8") + self.assertNotIn("secret-canary-value", raw) + self.assertEqual(vault.list_names(), ["oauth-signing/key-a"]) + self.assertEqual(vault.get_secret("oauth-signing/key-a"), "secret-canary-value") + with self.assertRaisesRegex(SecretVaultError, "incorrect|modified"): + SecretVault(path, "wrong-master-key").get_secret("oauth-signing/key-a") + self.assertTrue(vault.delete_secret("oauth-signing/key-a")) + self.assertFalse(vault.delete_secret("oauth-signing/key-a")) + + def test_atomic_replace_failure_preserves_previous_vault(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "oauth-secrets.json" + vault = SecretVault(path, "test-master-key") + vault.set_secret("stable", "old-value") + original = path.read_bytes() + + with patch.object( + secret_vault_module.os, + "replace", + side_effect=OSError("replace failed"), + ): + with self.assertRaises(SecretVaultError): + vault.set_secret("new", "new-value") + + self.assertEqual(path.read_bytes(), original) + self.assertEqual(vault.get_secret("stable"), "old-value") + self.assertEqual(list(path.parent.glob(f".{path.name}.*.tmp")), []) + + def test_unsupported_vault_version_is_rejected(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "oauth-secrets.json" + path.write_text('{"version": 99, "secrets": {}}\n', encoding="utf-8") + with self.assertRaisesRegex(SecretVaultError, "unsupported version"): + SecretVault(path, "test-master-key").list_names() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 39cc030..dcee8a8 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -248,13 +248,14 @@ def tearDown(self) -> None: def test_install_id_is_random_stable_and_resettable(self) -> None: with tempfile.TemporaryDirectory() as tmp: - with patch.dict(os.environ, {"HOME": tmp}): + with patch.object(Path, "home", return_value=Path(tmp)): first = telemetry.install_id() self.assertEqual(telemetry.install_id(), first) path = Path(tmp) / ".coding-tools-mcp" / "id" self.assertEqual(path.read_text(encoding="utf-8").strip(), first) - self.assertEqual(path.stat().st_mode & 0o777, 0o600) - self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) + if os.name != "nt": + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(path.parent.stat().st_mode & 0o777, 0o700) telemetry._install_id = None path.unlink() diff --git a/tests/test_webui.py b/tests/test_webui.py new file mode 100644 index 0000000..87cee59 --- /dev/null +++ b/tests/test_webui.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from coding_tools_mcp.webui import ADMIN_HTML, WEBUI_DIST, admin_console_html + + +ROOT = Path(__file__).resolve().parents[1] +WEBUI_SRC = ROOT / "webui" / "src" + + +class WebUIBuildTests(unittest.TestCase): + def test_packaged_admin_page_is_generated_self_contained_source(self) -> None: + self.assertEqual([path.name for path in WEBUI_DIST.iterdir() if path.is_file()], ["admin.html"]) + built = ADMIN_HTML.read_text(encoding="utf-8") + self.assertEqual(admin_console_html(), built) + for name in ( + "admin.css", + "settings-copy.js", + "settings-model.js", + "workspace-editor.js", + "settings-page.js", + "admin.js", + ): + source = (WEBUI_SRC / name).read_text(encoding="utf-8").strip() + self.assertIn(f'data-build-source="{name}"', built) + self.assertIn(source, built) + self.assertIsNone(re.search(r']*href=["\'][^"\']+\.css', built, re.I)) + self.assertIsNone(re.search(r']*src=["\'][^"\']+\.js', built, re.I)) + + def test_frontend_has_no_obsolete_or_unsafe_control_paths(self) -> None: + source = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(WEBUI_SRC.iterdir()) + if path.suffix in {".html", ".js"} + ) + self.assertNotRegex(source, r"(?i)tool_profile") + self.assertNotIn("innerHTML", source) + self.assertNotRegex(source, r"localStorage|sessionStorage") + self.assertNotRegex(source, r"reload_upstream|start_server|stop_server") + self.assertIn("stale_revision", source) + self.assertIn("textContent", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workspace_session_binding.py b/tests/test_workspace_session_binding.py new file mode 100644 index 0000000..a90031a --- /dev/null +++ b/tests/test_workspace_session_binding.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +import http.client +import json +import os +import shutil +import sqlite3 +import tempfile +import threading +import time +import unittest +from contextlib import closing, contextmanager +from unittest.mock import patch +from pathlib import Path +from typing import Any, Iterator + +from coding_tools_mcp.oauth import ( + OAuthIdentity, + OAuthServiceError, + create_access_token, + create_authorization_grant, +) +from coding_tools_mcp.server import ( + AuthorizationContext, + BoundRuntimeFactory, + MCPHandler, + RuntimeHTTPServer, + WorkspaceBinding, + apply_oauth_workspace_bindings, + build_parser, + build_persistent_oauth_config, + build_runtime, + load_project_context, + load_workspace_startup, + runtime_policy_from_args, +) +from coding_tools_mcp.settings_store import ServerSettingsStore +from coding_tools_mcp.workspace_binding import ( + WorkspaceBindingError, + WorkspaceBindingResolver, +) +from coding_tools_mcp.workspace_catalog import WorkspaceCatalog, WorkspaceEntry + + +@contextmanager +def test_root() -> Iterator[Path]: + root = Path(tempfile.mkdtemp()) + try: + yield root + finally: + database = root / "config" / "oauth.sqlite3" + if database.exists(): + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + for attempt in range(20): + try: + shutil.rmtree(root) + break + except FileNotFoundError: + break + except OSError as exc: + retryable = os.name == "nt" and getattr(exc, "winerror", None) in {5, 32, 145} + if not retryable or attempt == 19: + raise + time.sleep(0.05) + + +def rpc( + base_port: int, + token: str, + request: dict[str, Any], + *, + session_id: str | None = None, +) -> tuple[int, dict[str, str], dict[str, Any]]: + body = json.dumps(request).encode("utf-8") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2025-06-18", + } + if session_id: + headers["Mcp-Session-Id"] = session_id + connection = http.client.HTTPConnection("127.0.0.1", base_port, timeout=5) + try: + connection.request("POST", "/mcp", body=body, headers=headers) + response = connection.getresponse() + raw = response.read() + response_headers = {name.lower(): value for name, value in response.getheaders()} + payload = json.loads(raw) if raw else {} + return response.status, response_headers, payload + finally: + connection.close() + + +def delete_session(port: int, token: str, session_id: str) -> tuple[int, dict[str, Any]]: + connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) + try: + connection.request( + "DELETE", + "/mcp", + headers={ + "Authorization": f"Bearer {token}", + "Mcp-Session-Id": session_id, + }, + ) + response = connection.getresponse() + raw = response.read() + return response.status, json.loads(raw) if raw else {} + finally: + connection.close() + + +def initialize(port: int, token: str, request_id: int) -> tuple[str, dict[str, Any]]: + status, headers, response = rpc( + port, + token, + { + "jsonrpc": "2.0", + "id": request_id, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": f"agent-{request_id}", "version": "1"}, + }, + }, + ) + if status != 200: + raise AssertionError(response) + session_id = headers.get("mcp-session-id") + if not session_id: + raise AssertionError("initialize did not return Mcp-Session-Id") + return session_id, response["result"] + + +def call_tool( + port: int, + token: str, + session_id: str, + request_id: int, + name: str, + arguments: dict[str, Any], +) -> tuple[int, dict[str, Any]]: + status, _headers, response = rpc( + port, + token, + { + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": {"name": name, "arguments": arguments}, + }, + session_id=session_id, + ) + return status, response + + +class WorkspaceSessionBindingTests(unittest.TestCase): + def test_oauth_sessions_bind_immutable_isolated_workspaces(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + config_dir = root / "config" + first.mkdir() + second.mkdir() + (first / "identity.txt").write_text("FIRST", encoding="utf-8") + (second / "identity.txt").write_text("SECOND", encoding="utf-8") + (first / "AGENTS.md").write_text("FIRST-INSTRUCTIONS", encoding="utf-8") + (second / "AGENTS.md").write_text("SECOND-INSTRUCTIONS", encoding="utf-8") + (first / "subdir").mkdir() + (second / "subdir").mkdir() + + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=True), + ], + "first", + ) + resolver = WorkspaceBindingResolver(catalog) + oauth_config, _created = build_persistent_oauth_config( + config_dir, + master_key="synthetic-workspace-master-key", + password="synthetic-workspace-password", + server_url=None, + token_ttl=86_400, + registration_workspace_id=None, + ) + oauth_config.registry.add_preregistered( + "agent-first", + ("http://127.0.0.1/callback",), + client_secret=None, + workspace_id="first", + ) + oauth_config.registry.add_preregistered( + "agent-second", + ("http://127.0.0.1/callback",), + client_secret=None, + workspace_id="second", + ) + + args = build_parser().parse_args(["--workspace", str(first)]) + policy = runtime_policy_from_args(args) + control_binding = WorkspaceBinding("first", first, "control") + control_runtime = build_runtime( + args, + policy, + oauth_config=oauth_config, + project_context=load_project_context(first), + workspace_binding=control_binding, + authorization_context=AuthorizationContext("control"), + transport="http", + ) + factory = BoundRuntimeFactory( + args, + policy, + resolver, + auth_token=None, + oauth_config=oauth_config, + ) + server = RuntimeHTTPServer( + ("127.0.0.1", 0), + MCPHandler, + control_runtime, + factory, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = int(server.server_address[1]) + base = f"http://127.0.0.1:{port}" + + first_grant = create_authorization_grant( + oauth_config, + client_id="agent-first", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + second_grant = create_authorization_grant( + oauth_config, + client_id="agent-second", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + first_token = create_access_token( + oauth_config, + base, + client_id="agent-first", + grant_id=first_grant, + ) + second_token = create_access_token( + oauth_config, + base, + client_id="agent-second", + grant_id=second_grant, + ) + + try: + first_session, first_init = initialize(port, first_token, 1) + second_session, second_init = initialize(port, second_token, 2) + self.assertIn("FIRST-INSTRUCTIONS", first_init["instructions"]) + self.assertNotIn("SECOND-INSTRUCTIONS", first_init["instructions"]) + self.assertIn("SECOND-INSTRUCTIONS", second_init["instructions"]) + self.assertNotIn("FIRST-INSTRUCTIONS", second_init["instructions"]) + + first_status, first_read = call_tool( + port, first_token, first_session, 3, "read_file", {"path": "identity.txt"} + ) + second_status, second_read = call_tool( + port, second_token, second_session, 4, "read_file", {"path": "identity.txt"} + ) + self.assertEqual(first_status, 200) + self.assertEqual(second_status, 200) + self.assertEqual(first_read["result"]["structuredContent"]["content"], "FIRST") + self.assertEqual(second_read["result"]["structuredContent"]["content"], "SECOND") + + escape_status, escape = call_tool( + port, + first_token, + first_session, + 5, + "read_file", + {"path": str(second / "identity.txt")}, + ) + self.assertEqual(escape_status, 200) + self.assertTrue(escape["result"]["isError"]) + self.assertFalse(escape["result"]["structuredContent"]["ok"]) + + cwd_status, cwd = call_tool( + port, + first_token, + first_session, + 6, + "set_default_cwd", + {"path": "subdir"}, + ) + self.assertEqual(cwd_status, 200) + self.assertEqual(cwd["result"]["structuredContent"]["default_cwd"], "subdir") + _status, second_cwd = call_tool( + port, second_token, second_session, 7, "get_default_cwd", {} + ) + self.assertEqual( + second_cwd["result"]["structuredContent"]["default_cwd"], "." + ) + + first_runtime = server.sessions.get(first_session) + second_runtime = server.sessions.get(second_session) + self.assertIsNot(first_runtime, second_runtime) + self.assertEqual(first_runtime.workspace.root, first.resolve()) + self.assertEqual(second_runtime.workspace.root, second.resolve()) + self.assertIsNot(first_runtime.sessions, second_runtime.sessions) + self.assertIsNot(first_runtime.output_sessions, second_runtime.output_sessions) + self.assertIsNot(first_runtime.project_context, second_runtime.project_context) + + mismatch_status, _headers, mismatch = rpc( + port, + second_token, + { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": {"name": "get_default_cwd", "arguments": {}}, + }, + session_id=first_session, + ) + self.assertEqual(mismatch_status, 403) + self.assertIn("does not match", mismatch["error"]["message"]) + delete_status, delete_response = delete_session( + port, second_token, first_session + ) + self.assertEqual(delete_status, 403) + self.assertIn("does not match", delete_response["error"]["message"]) + still_alive_status, _still_alive = call_tool( + port, first_token, first_session, 81, "get_default_cwd", {} + ) + self.assertEqual(still_alive_status, 200) + + resolver.update_catalog( + WorkspaceCatalog( + [ + WorkspaceEntry( + "first", "First", first, enabled=True, default=True + ), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + ) + existing_status, existing = call_tool( + port, + second_token, + second_session, + 9, + "read_file", + {"path": "identity.txt"}, + ) + self.assertEqual(existing_status, 200) + self.assertEqual( + existing["result"]["structuredContent"]["content"], "SECOND" + ) + denied_status, _headers, denied = rpc( + port, + second_token, + { + "jsonrpc": "2.0", + "id": 10, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "disabled", "version": "1"}, + }, + }, + ) + self.assertEqual(denied_status, 503) + self.assertIn("Workspace mapping", denied["error"]["message"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + def test_startup_binding_migrates_only_a_single_enabled_workspace(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + single = WorkspaceCatalog( + [WorkspaceEntry("first", "First", first, enabled=True, default=True)], + "first", + ) + single_config, _created = build_persistent_oauth_config( + root / "single-config", + master_key="single-master-key", + password="single-password", + server_url=None, + token_ttl=86_400, + registration_workspace_id=None, + ) + single_config.registry.add_preregistered( + "single-agent", + ("http://127.0.0.1/callback",), + client_secret=None, + ) + self.assertIsNone(single_config.store.get_client("single-agent")["workspace_id"]) + apply_oauth_workspace_bindings(single_config, single, {}) + self.assertEqual( + single_config.store.get_client("single-agent")["workspace_id"], + "first", + ) + + multiple = WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=True), + ], + "first", + ) + multi_config, _created = build_persistent_oauth_config( + root / "multi-config", + master_key="multi-master-key", + password="multi-password", + server_url=None, + token_ttl=86_400, + registration_workspace_id=None, + ) + multi_config.registry.add_preregistered( + "multi-agent", + ("http://127.0.0.1/callback",), + client_secret=None, + ) + apply_oauth_workspace_bindings(multi_config, multiple, {}) + self.assertIsNone(multi_config.store.get_client("multi-agent")["workspace_id"]) + with self.assertRaises(OAuthServiceError): + create_authorization_grant( + multi_config, + client_id="multi-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + apply_oauth_workspace_bindings( + multi_config, + multiple, + {"multi-agent": "second"}, + ) + grant_id = create_authorization_grant( + multi_config, + client_id="multi-agent", + redirect_uri="http://127.0.0.1/callback", + scopes="mcp", + ) + self.assertEqual(multi_config.store.get_grant(grant_id)["workspace_id"], "second") + + def test_startup_loader_reads_catalog_from_server_settings(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + config_dir = root / "settings" + first.mkdir() + second.mkdir() + ServerSettingsStore(config_dir / "server-settings.json").write( + { + "workspace_catalog": [ + { + "id": "first", + "name": "First", + "root": str(first), + "enabled": True, + "default": True, + }, + { + "id": "second", + "name": "Second", + "root": str(second), + "enabled": True, + "default": False, + }, + ], + "default_workspace_id": "first", + "oauth_client_workspace_bindings": {"agent-second": "second"}, + } + ) + args = build_parser().parse_args(["--workspace", str(first)]) + with patch.dict( + os.environ, + {"CODING_TOOLS_MCP_CONFIG_DIR": str(config_dir)}, + clear=False, + ): + loaded_dir, settings, catalog = load_workspace_startup(args) + self.assertEqual(loaded_dir, config_dir) + self.assertEqual(catalog.default_id, "first") + self.assertEqual(catalog.get("second").root, second.resolve()) + self.assertEqual( + settings["oauth_client_workspace_bindings"], + {"agent-second": "second"}, + ) + + def test_resolver_fails_closed_and_stdio_uses_only_default_workspace(self) -> None: + with test_root() as root: + first = root / "first" + second = root / "second" + first.mkdir() + second.mkdir() + catalog = WorkspaceCatalog( + [ + WorkspaceEntry("first", "First", first, enabled=True, default=True), + WorkspaceEntry("second", "Second", second, enabled=False), + ], + "first", + ) + resolver = WorkspaceBindingResolver(catalog) + stdio = resolver.resolve_stdio() + self.assertEqual(stdio.workspace_id, "first") + self.assertEqual(stdio.authorization_method, "stdio") + with self.assertRaises(WorkspaceBindingError): + resolver.resolve_http("oauth", None) + with self.assertRaises(WorkspaceBindingError): + resolver.resolve_http( + "oauth", + OAuthIdentity("agent", "grant", "second", "jti"), + ) diff --git a/uv.lock b/uv.lock index a6bffa2..bb03c28 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ wheels = [ [[package]] name = "coding-tools-mcp" -version = "0.2.0" +version = "0.2.2" source = { editable = "." } dependencies = [ { name = "pyjwt" }, @@ -61,6 +61,7 @@ desktop = [ ] dev = [ { name = "mypy" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "typing-extensions" }, ] @@ -75,6 +76,7 @@ requires-dist = [ { name = "psutil", marker = "extra == 'desktop'", specifier = ">=7.0,<8" }, { name = "pyjwt", specifier = ">=2.8" }, { name = "pyside6", marker = "extra == 'desktop'", specifier = ">=6.8,<6.9" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15,<0.16" }, { name = "typing-extensions", marker = "extra == 'dev'", specifier = ">=4.12" }, ] @@ -391,6 +393,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0f/5d8c6da7586e57ee032643e0c0e62335ef1a1add1a980160ddd1654f1d8d/PySide6_Essentials-6.8.3-cp39-abi3-win_amd64.whl", hash = "sha256:3c0fae5550aff69f2166f46476c36e0ef56ce73d84829eac4559770b0c034b07", size = 72191029, upload-time = "2025-03-27T12:15:10.425Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "ruff" version = "0.15.17" diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..dfdba0a --- /dev/null +++ b/webui/package.json @@ -0,0 +1,11 @@ +{ + "name": "coding-tools-mcp-webui", + "private": true, + "type": "module", + "scripts": { + "test:models": "node --test tests/settings-model.test.mjs tests/security-model.test.mjs", + "test:dom": "node --test tests/dom-interactions.test.mjs", + "test": "npm run test:models && npm run test:dom", + "build": "node scripts/build.mjs" + } +} diff --git a/webui/scripts/build.mjs b/webui/scripts/build.mjs new file mode 100644 index 0000000..a28ee76 --- /dev/null +++ b/webui/scripts/build.mjs @@ -0,0 +1,38 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const srcDir = path.join(root, 'webui', 'src'); +const outDir = path.join(root, 'coding_tools_mcp', 'webui_dist'); +const scripts = [ + 'settings-copy.js', + 'settings-model.js', + 'workspace-editor.js', + 'settings-page.js', + 'admin.js', +]; + +const [html, css, modules] = await Promise.all([ + readFile(path.join(srcDir, 'admin.html'), 'utf8'), + readFile(path.join(srcDir, 'admin.css'), 'utf8'), + Promise.all(scripts.map(async (name) => [name, await readFile(path.join(srcDir, name), 'utf8')])), +]); + +let built = html.replace( + '', + ``, +); +for (const [name, source] of modules) { + built = built.replace( + ``, + ``, + ); +} +if (/]*href=["'][^"']+\.css|]*src=["'][^"']+\.js/i.test(built)) { + throw new Error('Build left an external WebUI asset reference in admin.html.'); +} +await rm(outDir, { recursive: true, force: true }); +await mkdir(outDir, { recursive: true }); +await writeFile(path.join(outDir, 'admin.html'), `${built.trim()}\n`, 'utf8'); +console.log('Built coding_tools_mcp/webui_dist/admin.html from webui/src/**'); diff --git a/webui/src/admin.css b/webui/src/admin.css new file mode 100644 index 0000000..e95d89f --- /dev/null +++ b/webui/src/admin.css @@ -0,0 +1,103 @@ +:root { + color-scheme: light dark; + --bg: #f4f6f8; + --surface: #ffffff; + --text: #17202a; + --muted: #56616d; + --line: #c9d1d9; + --accent: #075985; + --accent-text: #ffffff; + --danger: #b42318; + --warning: #8a4b08; + --good: #18794e; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +@media (prefers-color-scheme: dark) { + :root { --bg:#11161c; --surface:#1b232c; --text:#f4f7fa; --muted:#b6c0ca; --line:#43505d; --accent:#38bdf8; --accent-text:#06131b; --danger:#ff8a80; --warning:#ffca80; --good:#6ee7b7; } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); line-height: 1.5; } +button, input, select, textarea { font: inherit; } +button, input, select, textarea { min-height: 44px; } +button { border: 1px solid transparent; border-radius: .55rem; padding: .65rem .9rem; background: var(--accent); color: var(--accent-text); cursor: pointer; } +button:hover { filter: brightness(1.08); } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 2px; } +button.secondary { background: transparent; color: var(--text); border-color: var(--line); } +button.danger { background: var(--danger); color: #fff; } +button:disabled { opacity: .55; cursor: not-allowed; } +input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: .5rem; padding: .65rem; background: var(--surface); color: var(--text); } +textarea { resize: vertical; } +label { display: block; font-weight: 650; margin-bottom: .3rem; } +code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.skip-link { position: fixed; left: .5rem; top: -4rem; z-index: 100; background: var(--surface); color: var(--text); padding: .75rem; } +.skip-link:focus { top: .5rem; } +.topbar { display: flex; gap: 2rem; align-items: flex-start; justify-content: space-between; padding: 1rem 1.5rem; background: var(--surface); border-bottom: 1px solid var(--line); } +.topbar h1 { margin: 0; } +.eyebrow { margin: 0; text-transform: uppercase; letter-spacing: .08em; font-size: .75rem; color: var(--muted); } +.auth-form { display: grid; grid-template-columns: minmax(220px, 420px) auto auto; gap: .6rem; align-items: end; } +.help, .muted { color: var(--muted); font-size: .9rem; } +.help { margin: .3rem 0 0; } +.layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); min-height: calc(100vh - 100px); } +.sidebar { padding: 1rem; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: .4rem; background: var(--surface); } +.nav-item { text-align: left; background: transparent; color: var(--text); border-color: transparent; } +.nav-item.active { background: color-mix(in srgb, var(--accent) 16%, transparent); border-color: var(--accent); } +main { width: 100%; max-width: 1500px; padding: 1.25rem; } +.page-section { display: none; } +.page-section.active { display: block; } +.section-heading { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:1rem; } +.section-heading h2 { margin:.1rem 0; } +.card, .workspace-card { background: var(--surface); border:1px solid var(--line); border-radius:.8rem; padding:1rem; min-width:0; } +.card h3, .workspace-card h3 { margin-top:0; } +.card-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:1rem; margin-bottom:1rem; } +.stack { display:flex; flex-direction:column; gap:.75rem; } +.form-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; margin-bottom:1rem; } +.form-grid .full { grid-column:1/-1; } +.button-row, .toolbar, .pager { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem; } +.button-row.end { justify-content:flex-end; } +.toolbar { margin-bottom:1rem; } +.toolbar label { margin:0; } +.toolbar input, .toolbar select { width:auto; min-width:180px; } +.checkline { display:flex; align-items:center; gap:.6rem; font-weight:500; } +.checkline input { width:auto; min-height:auto; } +.status { min-height:2.2rem; padding:.65rem .8rem; margin-bottom:1rem; border-left:4px solid var(--accent); background:var(--surface); } +.alert { padding:.8rem; border:1px solid currentColor; border-radius:.6rem; margin-bottom:1rem; } +.alert.warning { color:var(--warning); } +.alert.danger { color:var(--danger); } +.badge { display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:.2rem .55rem; font-size:.8rem; } +.badge.good { color:var(--good); } +.badge.danger { color:var(--danger); } +.workspace-heading { display:flex; justify-content:space-between; gap:1rem; } +.compact-definition { display:grid; grid-template-columns:auto 1fr; gap:.3rem .7rem; } +.compact-definition dt { color:var(--muted); } +.compact-definition dd { margin:0; min-width:0; } +.code-block { max-height:360px; overflow:auto; white-space:pre-wrap; background:color-mix(in srgb,var(--bg) 75%,transparent); padding:.75rem; border-radius:.5rem; } +.danger-zone { border-color: color-mix(in srgb, var(--danger) 55%, var(--line)); margin-bottom:1rem; } +details > summary { cursor:pointer; font-weight:700; } +.split-view { display:grid; grid-template-columns:minmax(280px,.8fr) minmax(0,1.2fr); gap:1rem; } +.conversation-card button { width:100%; text-align:left; background:transparent; color:var(--text); border-color:var(--line); } +.conversation-card h3 { margin:.1rem 0; } +.message, .context-entry { border-left:4px solid var(--line); padding:.75rem; margin:.75rem 0; background:color-mix(in srgb,var(--surface) 92%,var(--bg)); } +.message pre, .context-entry pre { white-space:pre-wrap; overflow-wrap:anywhere; } +.data-table { width:100%; border-collapse:collapse; } +.data-table th, .data-table td { border-bottom:1px solid var(--line); padding:.55rem; text-align:left; vertical-align:top; } +.data-table th { color:var(--muted); } +dialog { border:1px solid var(--line); border-radius:.8rem; background:var(--surface); color:var(--text); max-width:560px; width:calc(100% - 2rem); } +dialog::backdrop { background:rgba(0,0,0,.55); } +.dialog-card { padding:.5rem; } +[hidden] { display:none !important; } +@media (max-width: 850px) { + .topbar { flex-direction:column; } + .auth-form { grid-template-columns:1fr auto auto; width:100%; } + .layout { grid-template-columns:1fr; } + .sidebar { position:sticky; top:0; z-index:10; flex-direction:row; overflow-x:auto; border-right:0; border-bottom:1px solid var(--line); } + .nav-item { white-space:nowrap; } + .split-view, .form-grid { grid-template-columns:1fr; } + .form-grid .full { grid-column:auto; } +} +@media (max-width: 560px) { + .auth-form { grid-template-columns:1fr 1fr; } + .auth-form > div { grid-column:1/-1; } + .section-heading { align-items:flex-start; flex-direction:column; } + main { padding:.8rem; } + .toolbar input, .toolbar select { width:100%; } +} diff --git a/webui/src/admin.html b/webui/src/admin.html new file mode 100644 index 0000000..f04384e --- /dev/null +++ b/webui/src/admin.html @@ -0,0 +1,146 @@ + + + + + + + Coding Tools MCP Admin + + + + +
+
+

coding-tools-mcp

+

Admin Console

+
+
+
+ + +

仅保存在当前页面内存中;不会写入 URL、浏览器持久化存储、日志或服务器设置。

+
+ + +
+
+ +
+ + +
+
输入专用 Admin token 后连接。
+ +
+

Runtime

概览

+
+

Admin API

未连接

普通 MCP bearer 不具备管理员权限。

+

Gateway

未连接

工具快照在 Runtime 初始化时冻结,不支持热 reload。

+

Telemetry

未报告

+

Vault

未连接

页面从不读取或显示 Secret 值。

+
+
+ + + + + + + + + + + + +
+
+ + +
+

确认操作

+

+
+
+
+ + + + + + + + diff --git a/webui/src/admin.js b/webui/src/admin.js new file mode 100644 index 0000000..5f7b549 --- /dev/null +++ b/webui/src/admin.js @@ -0,0 +1,596 @@ +class ApiError extends Error { + constructor(status, payload, message) { + super(message || payload?.error?.message || `Admin request failed with HTTP ${status}.`); + this.name = 'ApiError'; + this.status = status; + this.payload = payload; + } +} + +const FORBIDDEN_RESPONSE_KEYS = new Set([ + 'client_secret', + 'client_secret_digest', + 'refresh_token', + 'access_token', + 'token_hash', + 'signing_secret', + 'secret_ref', +]); + +function sensitiveKey(key) { + const normalized = String(key || '').toLowerCase(); + return FORBIDDEN_RESPONSE_KEYS.has(normalized) + || normalized.endsWith('_secret_ref') + || normalized.endsWith('_digest') + || normalized.endsWith('_hash'); +} + +function sanitizeAdminValue(value) { + if (Array.isArray(value)) return value.map(sanitizeAdminValue); + if (!value || typeof value !== 'object') return value; + const result = {}; + for (const [key, child] of Object.entries(value)) { + if (sensitiveKey(key)) continue; + if (key === 'source' && child === 'secret_ref') { + result.source = 'configured credential'; + continue; + } + result[key] = sanitizeAdminValue(child); + } + return result; +} + +function containsCredentialControl(value) { + if (Array.isArray(value)) return value.some(containsCredentialControl); + if (!value || typeof value !== 'object') return false; + return Object.entries(value).some(([key, child]) => { + const normalized = key.toLowerCase(); + if (normalized === 'secret_ref' || normalized === 'env_ref') return true; + if (/authorization|api[-_]?key|token|password|credential|secret/.test(normalized)) return true; + return containsCredentialControl(child); + }); +} + +function createApiClient(getToken, fetchImpl = globalThis.fetch) { + async function request(path, options = {}) { + const token = String(getToken?.() || ''); + const headers = new Headers(options.headers || {}); + headers.set('Accept', 'application/json'); + if (options.body !== undefined) headers.set('Content-Type', 'application/json'); + if (token) headers.set('Authorization', `Bearer ${token}`); + const response = await fetchImpl(`/admin/api${path}`, { + method: options.method || 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + credentials: 'same-origin', + cache: 'no-store', + }); + let payload = {}; + try { payload = await response.json(); } catch { payload = {}; } + if (!response.ok) throw new ApiError(response.status, payload); + return payload; + } + return { request }; +} + +function createNode(documentRef, tag, options = {}) { + const node = documentRef.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = String(options.text); + if (options.type) node.type = options.type; + if (options.id) node.id = options.id; + return node; +} + +function appendDefinitionList(documentRef, container, value) { + const safe = sanitizeAdminValue(value); + const dl = createNode(documentRef, 'dl', { className: 'compact-definition' }); + for (const [key, child] of Object.entries(safe || {})) { + if (typeof child === 'object' && child !== null) continue; + dl.append( + createNode(documentRef, 'dt', { text: key }), + createNode(documentRef, 'dd', { text: child ?? '—' }), + ); + } + container.append(dl); +} + +function renderConversationItems(container, items, onSelect) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + if (!items?.length) { + container.append(createNode(documentRef, 'p', { className: 'muted', text: '没有匹配的会话摘要。' })); + return; + } + for (const item of items) { + const card = createNode(documentRef, 'article', { className: 'conversation-card' }); + const button = createNode(documentRef, 'button', { type: 'button' }); + const title = createNode(documentRef, 'h3', { text: item.title || item.conversation_id || 'Untitled conversation' }); + const identity = createNode(documentRef, 'p', { className: 'muted', text: `${item.workspace_id || '—'} / ${item.conversation_id || '—'}` }); + const preview = createNode(documentRef, 'p', { text: item.preview || '无摘要正文。' }); + const counts = createNode(documentRef, 'p', { className: 'muted', text: `Messages: ${item.message_count || 0} · Context: ${item.context_count || 0}` }); + button.append(title, identity, preview, counts); + button.addEventListener('click', () => onSelect?.(item, button)); + card.append(button); + container.append(card); + } +} + +function renderConversationDetail(container, payload, handlers = {}) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + const conversation = payload?.conversation || {}; + const heading = createNode(documentRef, 'div', { className: 'section-heading' }); + const titleWrap = createNode(documentRef, 'div'); + titleWrap.append( + createNode(documentRef, 'p', { className: 'eyebrow', text: `${conversation.workspace_id || '—'} / ${conversation.conversation_id || '—'}` }), + createNode(documentRef, 'h3', { text: conversation.title || conversation.conversation_id || 'Conversation detail' }), + ); + const deleteConversation = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除会话' }); + deleteConversation.addEventListener('click', () => handlers.onDeleteConversation?.(conversation, deleteConversation)); + heading.append(titleWrap, deleteConversation); + container.append(heading); + + const messagesHeading = createNode(documentRef, 'h4', { text: `Messages (${payload.messages_total || 0})` }); + container.append(messagesHeading); + for (const message of payload.messages || []) { + const card = createNode(documentRef, 'section', { className: 'message' }); + const meta = createNode(documentRef, 'p', { className: 'muted', text: `${message.role || 'unknown'} · ${message.message_id || '—'} · ${message.timestamp || '—'}` }); + const content = createNode(documentRef, 'pre', { text: message.content || '' }); + const remove = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除 message' }); + remove.addEventListener('click', () => handlers.onDeleteMessage?.(message, remove)); + card.append(meta, content, remove); + container.append(card); + } + const messagePager = createNode(documentRef, 'div', { className: 'pager' }); + const previousMessages = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Messages 上一页' }); + previousMessages.disabled = (payload.message_page || 1) <= 1; + previousMessages.addEventListener('click', () => handlers.onMessagePage?.((payload.message_page || 1) - 1)); + const nextMessages = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Messages 下一页' }); + nextMessages.disabled = (payload.message_page || 1) * (payload.message_page_size || 100) >= (payload.messages_total || 0); + nextMessages.addEventListener('click', () => handlers.onMessagePage?.((payload.message_page || 1) + 1)); + messagePager.append(previousMessages, createNode(documentRef, 'span', { text: `第 ${payload.message_page || 1} 页` }), nextMessages); + container.append(messagePager); + + container.append(createNode(documentRef, 'h4', { text: `Context (${payload.contexts_total || 0})` })); + for (const entry of payload.contexts || []) { + const card = createNode(documentRef, 'section', { className: 'context-entry' }); + card.append( + createNode(documentRef, 'p', { className: 'muted', text: `${entry.kind || 'context'} · ${entry.context_id || '—'} · ${entry.timestamp || '—'}` }), + createNode(documentRef, 'pre', { text: entry.content || '' }), + ); + const remove = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除 context' }); + remove.addEventListener('click', () => handlers.onDeleteContext?.(entry, remove)); + card.append(remove); + container.append(card); + } + const contextPager = createNode(documentRef, 'div', { className: 'pager' }); + const previousContext = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Context 上一页' }); + previousContext.disabled = (payload.context_page || 1) <= 1; + previousContext.addEventListener('click', () => handlers.onContextPage?.((payload.context_page || 1) - 1)); + const nextContext = createNode(documentRef, 'button', { type: 'button', className: 'secondary', text: 'Context 下一页' }); + nextContext.disabled = (payload.context_page || 1) * (payload.context_page_size || 100) >= (payload.contexts_total || 0); + nextContext.addEventListener('click', () => handlers.onContextPage?.((payload.context_page || 1) + 1)); + contextPager.append(previousContext, createNode(documentRef, 'span', { text: `第 ${payload.context_page || 1} 页` }), nextContext); + container.append(contextPager); +} + +function renderOAuthItems(container, items, collection, onAction) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + if (!items?.length) { + container.append(createNode(documentRef, 'p', { className: 'muted', text: '没有记录。' })); + return; + } + const actionMap = { + clients: ['enable', 'disable'], + grants: ['revoke'], + tokens: ['revoke'], + 'refresh-families': ['revoke'], + 'signing-keys': ['activate', 'retire', 'revoke'], + }; + const idKey = { + clients: 'client_id', grants: 'grant_id', tokens: 'jti', + 'refresh-families': 'family_id', 'signing-keys': 'kid', audit: 'event_id', + }[collection]; + for (const original of items) { + const item = sanitizeAdminValue(original); + const card = createNode(documentRef, 'article', { className: 'card' }); + card.append(createNode(documentRef, 'h3', { text: String(item?.[idKey] || `${collection} item`) })); + appendDefinitionList(documentRef, card, item); + if (Object.values(item || {}).some((value) => value && typeof value === 'object')) { + const details = createNode(documentRef, 'details'); + const summary = createNode(documentRef, 'summary', { text: '查看脱敏结构' }); + const pre = createNode(documentRef, 'pre', { className: 'code-block', text: JSON.stringify(item, null, 2) }); + details.append(summary, pre); + card.append(details); + } + const actions = createNode(documentRef, 'div', { className: 'button-row' }); + for (const action of actionMap[collection] || []) { + const button = createNode(documentRef, 'button', { type: 'button', className: action === 'enable' || action === 'activate' ? 'secondary' : 'danger', text: action }); + button.addEventListener('click', () => onAction?.(collection, String(item?.[idKey] || ''), action, button)); + actions.append(button); + } + if (actions.childNodes.length) card.append(actions); + container.append(card); + } +} + +function confirmDestructive(documentRef, { title, message, confirmLabel = '确认', returnFocus } = {}) { + const dialog = documentRef.getElementById('confirmDialog'); + if (!dialog || typeof dialog.showModal !== 'function') { + return Promise.resolve(globalThis.confirm ? globalThis.confirm(message || title || '确认操作?') : false); + } + documentRef.getElementById('confirmTitle').textContent = title || '确认操作'; + documentRef.getElementById('confirmMessage').textContent = message || ''; + documentRef.getElementById('confirmAccept').textContent = confirmLabel; + return new Promise((resolve) => { + const finish = () => { + dialog.removeEventListener('close', finish); + const accepted = dialog.returnValue === 'confirm'; + if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); + resolve(accepted); + }; + dialog.addEventListener('close', finish); + dialog.showModal(); + }); +} + +async function handleSettingsSave({ api, state, documentRef }) { + const model = globalThis.McpSettingsModel; + const page = globalThis.McpSettingsPage; + state.settings.draft = model.serializeSettings(page.collectSettingsDraft(documentRef, state.settings.draft)); + try { + const payload = await api.request('/settings', { + method: 'PUT', + body: { expected_revision: state.settings.persistedRevision, updates: state.settings.draft }, + }); + const safePayload = { ...payload, active: sanitizeAdminValue(payload.active), persisted: sanitizeAdminValue(payload.persisted) }; + state.settings = model.hydrateSettings(safePayload); + page.renderSettingsForm(documentRef, state.settings, globalThis.McpSettingsCopy.permissionPresentation); + page.renderFormError(documentRef, ''); + return { saved: true, conflict: false }; + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const latest = await api.request('/settings'); + const safeLatest = { ...latest, active: sanitizeAdminValue(latest.active), persisted: sanitizeAdminValue(latest.persisted) }; + state.settings = model.refreshPersistedKeepingDraft(state.settings, safeLatest); + page.renderSettingsForm(documentRef, state.settings, globalThis.McpSettingsCopy.permissionPresentation); + const conflict = documentRef.getElementById('settingsConflict'); + conflict?.focus(); + return { saved: false, conflict: true }; + } + throw error; + } +} + +function initAdminApp(documentRef = document) { + const model = globalThis.McpSettingsModel; + const copy = globalThis.McpSettingsCopy; + const workspaceEditor = globalThis.McpWorkspaceEditor; + const settingsPage = globalThis.McpSettingsPage; + const state = { + token: '', settings: null, workspaces: [], workspaceRevision: '', gateway: null, + gatewayRevision: '', conversationPage: 1, conversationTotal: 0, + selectedConversation: null, messagePage: 1, contextPage: 1, + }; + const api = createApiClient(() => state.token); + const byId = (id) => documentRef.getElementById(id); + + function status(message, kind = '') { + const box = byId('globalStatus'); + if (!box) return; + box.textContent = message; + box.className = `status ${kind}`.trim(); + } + + function showSection(name) { + for (const section of documentRef.querySelectorAll('.page-section')) { + const active = section.id === `section-${name}`; + section.hidden = !active; + section.classList.toggle('active', active); + } + for (const button of documentRef.querySelectorAll('.nav-item')) { + button.classList.toggle('active', button.dataset.section === name); + } + byId('mainContent')?.focus(); + } + + async function loadOverview() { + const payload = await api.request('/status'); + byId('adminApiStatus').textContent = payload.admin_api ? '可用' : '不可用'; + byId('gatewayRuntimeStatus').textContent = payload.gateway?.available ? '已配置(快照不可变)' : '不可用'; + byId('vaultStatus').textContent = payload.vault?.enabled ? '已启用' : '未启用'; + const telemetry = copy.telemetryPresentation(payload.telemetry); + byId('telemetryStatus').textContent = telemetry.label; + byId('telemetryDetail').textContent = telemetry.detail; + byId('telemetryDisableHelp').textContent = copy.TELEMETRY_DISABLE_HELP; + byId('fakeReadonlyStatus').textContent = payload.runtime?.annotation_override === 'fake_readonly' ? '已启用' : payload.runtime ? '未启用' : '未报告'; + return payload; + } + + function safeSettingsPayload(payload) { + return { ...payload, active: sanitizeAdminValue(payload.active), persisted: sanitizeAdminValue(payload.persisted) }; + } + + async function loadSettings({ preserveDraft = false } = {}) { + const payload = safeSettingsPayload(await api.request('/settings')); + state.settings = preserveDraft && state.settings + ? model.refreshPersistedKeepingDraft(state.settings, payload) + : model.hydrateSettings(payload); + settingsPage.renderSettingsForm(documentRef, state.settings, copy.permissionPresentation); + return payload; + } + + async function loadWorkspaces() { + const payload = await api.request('/workspaces'); + state.workspaces = payload.workspace_catalog || []; + state.workspaceRevision = payload.persisted_revision || ''; + workspaceEditor.renderWorkspaceRows(byId('workspaceList'), state.workspaces, { + onCheck: async (workspace) => { + const result = await api.request(`/workspaces/${encodeURIComponent(workspace.id)}/check`); + status(`Workspace ${workspace.id}: exists=${result.check?.exists}, directory=${result.check?.is_directory}`); + }, + onDefault: async (workspace, button) => { + const accepted = await confirmDestructive(documentRef, { + title: '更改默认 Workspace', + message: `Workspace ID: ${workspace.id}\n影响:新的 Runtime/Session 将使用此默认 Workspace;已有 Session 绑定不变。`, + confirmLabel: '设为默认', returnFocus: button, + }); + if (!accepted) return; + await api.request(`/workspaces/${encodeURIComponent(workspace.id)}/default`, { method: 'POST', body: { expected_revision: state.workspaceRevision } }); + await Promise.all([loadWorkspaces(), loadSettings()]); + status(`默认 Workspace 已改为 ${workspace.id};需要按返回状态重启。`); + }, + onDisable: async (workspace, button) => { + const accepted = await confirmDestructive(documentRef, { + title: '禁用 Workspace', + message: `Workspace ID: ${workspace.id}\n影响:新的 Session 将无法绑定此 Workspace;已有 Session 保持冻结直到关闭。`, + confirmLabel: '禁用', returnFocus: button, + }); + if (!accepted) return; + await api.request(`/workspaces/${encodeURIComponent(workspace.id)}/disable`, { method: 'POST', body: { expected_revision: state.workspaceRevision } }); + await Promise.all([loadWorkspaces(), loadSettings()]); + status(`Workspace ${workspace.id} 已禁用。`); + }, + }); + workspaceEditor.populateWorkspaceSelect(byId('chatWorkspace'), state.workspaces, byId('chatWorkspace')?.value || payload.default_workspace_id); + return payload; + } + + function renderGateway(payload) { + state.gateway = payload; + state.gatewayRevision = payload.persisted_revision || ''; + byId('gatewayRestartRequired').textContent = payload.restart_required ? '是' : '否'; + byId('gatewayRevision').textContent = state.gatewayRevision || '—'; + const summary = byId('gatewaySummary'); + summary.replaceChildren(); + const servers = payload.persisted?.servers || {}; + const aliases = Object.keys(servers); + if (!aliases.length) summary.append(createNode(documentRef, 'p', { className: 'muted', text: '没有持久化 Gateway server。' })); + for (const alias of aliases) { + const raw = servers[alias] || {}; + const item = createNode(documentRef, 'article', { className: 'card' }); + item.append( + createNode(documentRef, 'h4', { text: alias }), + createNode(documentRef, 'p', { text: `Transport: ${raw.transport || 'unknown'} · Enabled: ${raw.enabled !== false}` }), + createNode(documentRef, 'p', { className: 'muted', text: 'Credential fields are configured but intentionally hidden.' }), + ); + summary.append(item); + } + } + + async function loadGateway() { const payload = await api.request('/gateway'); renderGateway(payload); return payload; } + + async function loadOAuth() { + const collection = byId('oauthCollection').value; + const payload = await api.request(`/oauth/${encodeURIComponent(collection)}`); + renderOAuthItems(byId('oauthList'), payload.items || [], collection, async (resource, id, action, button) => { + const accepted = await confirmDestructive(documentRef, { + title: `OAuth ${action}`, + message: `Resource: ${resource}\nID: ${id}\n影响:仅对该精确 ID 执行幂等状态变更。`, + confirmLabel: action, returnFocus: button, + }); + if (!accepted) return; + const result = await api.request(`/oauth/${encodeURIComponent(resource)}/${encodeURIComponent(id)}/${encodeURIComponent(action)}`, { method: 'POST', body: {} }); + status(`OAuth ${action} 完成,实际影响数量:${result.affected_count || 0}。`); + await loadOAuth(); + }); + return payload; + } + + async function loadSecrets() { + const payload = await api.request('/secrets'); + const root = byId('secretList'); + root.replaceChildren(); + for (const item of payload.secrets || []) { + const card = createNode(documentRef, 'article', { className: 'card' }); + card.append(createNode(documentRef, 'strong', { text: item.name })); + const remove = createNode(documentRef, 'button', { type: 'button', className: 'danger', text: '删除' }); + remove.addEventListener('click', async () => { + const accepted = await confirmDestructive(documentRef, { + title: '删除 Secret Vault 条目', + message: `Secret 名称: ${item.name}\n影响:删除该名称对应的一个 Vault 值;值本身不会显示。`, + confirmLabel: '删除', returnFocus: remove, + }); + if (!accepted) return; + const result = await api.request(`/secrets/${encodeURIComponent(item.name)}`, { method: 'DELETE' }); + status(`Secret ${item.name} 删除影响数量:${result.affected_count || 0}。`); + await loadSecrets(); + }); + card.append(remove); + root.append(card); + } + if (!(payload.secrets || []).length) root.append(createNode(documentRef, 'p', { className: 'muted', text: 'Vault 中没有已配置名称。' })); + } + + async function loadConversations() { + const workspaceId = byId('chatWorkspace').value; + if (!workspaceId) return; + const query = new URLSearchParams({ workspace_id: workspaceId, page: String(state.conversationPage), page_size: '20' }); + const search = byId('chatQuery').value.trim(); + if (search) query.set('query', search); + const payload = await api.request(`/chat/conversations?${query}`); + state.conversationTotal = payload.total || 0; + byId('conversationPage').textContent = `第 ${payload.page || 1} 页`; + byId('conversationPrev').disabled = state.conversationPage <= 1; + byId('conversationNext').disabled = state.conversationPage * (payload.page_size || 20) >= state.conversationTotal; + renderConversationItems(byId('conversationList'), payload.items || [], async (item) => { + state.selectedConversation = { workspaceId: item.workspace_id, conversationId: item.conversation_id }; + state.messagePage = 1; state.contextPage = 1; + await loadConversationDetail(); + }); + } + + async function deleteChatResource(resource, workspaceId, identifier, button, detailMessage) { + const accepted = await confirmDestructive(documentRef, { + title: `删除 ${resource}`, + message: `Workspace ID: ${workspaceId}\nObject ID: ${identifier}\n影响:${detailMessage}`, + confirmLabel: '删除', returnFocus: button, + }); + if (!accepted) return false; + const result = await api.request(`/chat/${resource}/${encodeURIComponent(workspaceId)}/${encodeURIComponent(identifier)}`, { method: 'DELETE' }); + status(`删除完成,实际影响数量:${result.affected_count || 0}。`); + return true; + } + + async function loadConversationDetail() { + const selected = state.selectedConversation; + if (!selected) return; + const query = new URLSearchParams({ message_page: String(state.messagePage), message_page_size: '50', context_page: String(state.contextPage), context_page_size: '50' }); + const payload = await api.request(`/chat/conversations/${encodeURIComponent(selected.workspaceId)}/${encodeURIComponent(selected.conversationId)}?${query}`); + renderConversationDetail(byId('conversationDetail'), payload, { + onDeleteMessage: async (message, button) => { + if (await deleteChatResource('messages', selected.workspaceId, message.message_id, button, '最多删除 1 条 message。')) await loadConversationDetail(); + }, + onDeleteContext: async (entry, button) => { + if (await deleteChatResource('context', selected.workspaceId, entry.context_id, button, '最多删除 1 条 context entry。')) await loadConversationDetail(); + }, + onDeleteConversation: async (_conversation, button) => { + const accepted = await confirmDestructive(documentRef, { + title: '删除 Conversation', + message: `Workspace ID: ${selected.workspaceId}\nConversation ID: ${selected.conversationId}\n影响:删除该会话以及其所有 messages 和 context entries。`, + confirmLabel: '删除会话', returnFocus: button, + }); + if (!accepted) return; + const result = await api.request(`/chat/conversations/${encodeURIComponent(selected.workspaceId)}/${encodeURIComponent(selected.conversationId)}`, { method: 'DELETE' }); + status(`会话删除:conversation=${result.affected_count || 0}, messages=${result.deleted_message_count || 0}, context=${result.deleted_context_count || 0}。`); + state.selectedConversation = null; + byId('conversationDetail').replaceChildren(createNode(documentRef, 'p', { className: 'muted', text: '会话已删除。' })); + await loadConversations(); + }, + onMessagePage: async (page) => { state.messagePage = page; await loadConversationDetail(); }, + onContextPage: async (page) => { state.contextPage = page; await loadConversationDetail(); }, + }); + } + + async function refreshAll() { + status('正在读取 Admin API…'); + await Promise.all([loadOverview(), loadSettings(), loadWorkspaces(), loadGateway(), loadSecrets()]); + await Promise.all([loadOAuth(), loadConversations()]); + status('Admin 数据已刷新。'); + } + + byId('fakeReadonlyLabel').textContent = copy.FAKE_READONLY_COPY.label; + byId('fakeReadonlyWarning').textContent = copy.FAKE_READONLY_COPY.warning; + byId('fakeReadonlyEnable').textContent = copy.FAKE_READONLY_COPY.enable; + + for (const button of documentRef.querySelectorAll('.nav-item')) { + button.addEventListener('click', () => showSection(button.dataset.section)); + } + byId('authForm').addEventListener('submit', async (event) => { + event.preventDefault(); + state.token = byId('adminToken').value; + try { await refreshAll(); } catch (error) { status(error.message, 'danger'); } + }); + byId('forgetToken').addEventListener('click', () => { + state.token = ''; + byId('adminToken').value = ''; + status('Admin token 已从页面内存清除。'); + }); + byId('refreshAll').addEventListener('click', () => refreshAll().catch((error) => status(error.message, 'danger'))); + byId('reloadSettings').addEventListener('click', () => loadSettings().then(() => status('Settings 已重新读取。')).catch((error) => status(error.message, 'danger'))); + byId('saveSettings').addEventListener('click', async () => { + try { + const result = await handleSettingsSave({ api, state, documentRef }); + status(result.conflict ? '检测到 stale revision;草稿已保留,请审阅后重新保存。' : 'Settings 已保存;查看 pending restart。', result.conflict ? 'warning' : ''); + await loadWorkspaces(); + } catch (error) { settingsPage.renderFormError(documentRef, error.message); status(error.message, 'danger'); } + }); + byId('settingsPermission').addEventListener('change', () => { + byId('permissionHelp').textContent = copy.permissionPresentation(byId('settingsPermission').value).description; + }); + byId('reloadWorkspaces').addEventListener('click', () => loadWorkspaces().catch((error) => status(error.message, 'danger'))); + byId('workspaceAddForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const workspace = { + id: byId('workspaceId').value.trim(), name: byId('workspaceName').value.trim(), root: byId('workspaceRoot').value.trim(), + enabled: byId('workspaceEnabled').checked, default: byId('workspaceDefault').checked, + }; + try { + await api.request('/workspaces', { method: 'POST', body: { expected_revision: state.workspaceRevision, workspace } }); + event.currentTarget.reset(); byId('workspaceEnabled').checked = true; + await Promise.all([loadWorkspaces(), loadSettings()]); + status(`Workspace ${workspace.id} 已添加。`); + } catch (error) { status(error.message, 'danger'); } + }); + byId('reloadGateway').addEventListener('click', () => loadGateway().catch((error) => status(error.message, 'danger'))); + byId('clearGatewayDraft').addEventListener('click', () => { byId('gatewayDocument').value = ''; }); + byId('gatewayForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const draft = byId('gatewayDocument').value; + try { + const documentValue = JSON.parse(draft || '{"servers":{}}'); + if (containsCredentialControl(documentValue)) throw new Error('Gateway WebUI 草稿不得包含 credential、secret reference 或敏感 header/env 字段。'); + const result = await api.request('/gateway', { method: 'PUT', body: { expected_revision: state.gatewayRevision, document: documentValue } }); + byId('gatewayDocument').value = ''; + renderGateway(result); + status(`Gateway 配置已持久化。restart_required=${Boolean(result.restart_required)};现有 Runtime 未热加载。`); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + await loadGateway(); + status('Gateway revision 冲突;草稿已保留,persisted revision 已刷新。', 'warning'); + } else status(error.message, 'danger'); + } + }); + byId('oauthCollection').addEventListener('change', () => loadOAuth().catch((error) => status(error.message, 'danger'))); + byId('reloadOAuth').addEventListener('click', () => loadOAuth().catch((error) => status(error.message, 'danger'))); + byId('reloadSecrets').addEventListener('click', () => loadSecrets().catch((error) => status(error.message, 'danger'))); + byId('secretForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const name = byId('secretName').value.trim(); + const value = byId('secretValue').value; + try { + const result = await api.request(`/secrets/${encodeURIComponent(name)}`, { method: 'PUT', body: { value } }); + byId('secretValue').value = ''; + status(`Secret ${name} 已配置;实际影响数量:${result.affected_count || 0}。`); + await loadSecrets(); + } catch (error) { byId('secretValue').value = ''; status(error.message, 'danger'); } + }); + byId('chatWorkspace').addEventListener('change', () => { state.conversationPage = 1; state.selectedConversation = null; loadConversations().catch((error) => status(error.message, 'danger')); }); + byId('searchConversations').addEventListener('click', () => { state.conversationPage = 1; loadConversations().catch((error) => status(error.message, 'danger')); }); + byId('reloadConversations').addEventListener('click', () => loadConversations().catch((error) => status(error.message, 'danger'))); + byId('conversationPrev').addEventListener('click', () => { if (state.conversationPage > 1) { state.conversationPage -= 1; loadConversations().catch((error) => status(error.message, 'danger')); } }); + byId('conversationNext').addEventListener('click', () => { state.conversationPage += 1; loadConversations().catch((error) => status(error.message, 'danger')); }); + + return { state, api, refreshAll, loadSettings, loadWorkspaces, loadGateway, loadOAuth, loadSecrets, loadConversations, loadConversationDetail, showSection }; +} + +globalThis.McpAdminApp = { + ApiError, + sanitizeAdminValue, + containsCredentialControl, + createApiClient, + renderConversationItems, + renderConversationDetail, + renderOAuthItems, + confirmDestructive, + handleSettingsSave, + initAdminApp, +}; + +if (typeof document !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => initAdminApp(document)); +} + +export { ApiError, sanitizeAdminValue, containsCredentialControl, createApiClient, renderConversationItems, renderConversationDetail, renderOAuthItems, confirmDestructive, handleSettingsSave, initAdminApp }; diff --git a/webui/src/settings-copy.js b/webui/src/settings-copy.js new file mode 100644 index 0000000..04fe413 --- /dev/null +++ b/webui/src/settings-copy.js @@ -0,0 +1,50 @@ +const PERMISSION_COPY = Object.freeze({ + safe: Object.freeze({ + label: '安全模式', + description: '保留完整固定工具目录,同时限制网络、Shell 展开和高风险命令。它不会隐藏或禁用 mutation tools。', + }), + trusted: Object.freeze({ + label: '可信本地模式', + description: '保留完整固定工具目录,并允许更多本机网络与 Shell 能力。仅用于受信任的本地环境。', + }), + dangerous: Object.freeze({ + label: '危险模式', + description: '关闭主要命令权限门。只应在隔离容器或虚拟机中使用。', + }), +}); + +const FAKE_READONLY_COPY = Object.freeze({ + label: '伪只读 annotations 兼容覆盖', + warning: '此高级兼容选项只会向客户端伪报 readOnlyHint。它不会隐藏工具、阻止 mutation、改变 handler,或形成安全边界;并且只能配合 dangerous 模式启动。', + enable: '通过 --dangerously-fake-readonly-annotations 或 CODING_TOOLS_MCP_DANGEROUSLY_FAKE_READONLY_ANNOTATIONS=1 启用,然后重启服务。', +}); + +function permissionPresentation(mode) { + return PERMISSION_COPY[mode] || { label: String(mode || '未知'), description: '后端返回了未知 permission mode。' }; +} + +function telemetryPresentation(value) { + const mode = typeof value === 'string' ? value : value?.mode; + if (mode === 'off' || mode === 'disabled') { + return { mode: 'off', label: '关闭', detail: '不会发送匿名 telemetry。' }; + } + if (mode === 'debug') { + return { mode: 'debug', label: '调试', detail: '事件仅写入 stderr,不发送到远端。' }; + } + if (mode === 'enabled' || mode === 'on') { + return { mode: 'enabled', label: '启用', detail: '使用上游 v0.2.2 默认匿名 telemetry 策略。' }; + } + return { mode: 'unknown', label: '未报告', detail: '当前 Admin 后端尚未报告 telemetry 运行模式。' }; +} + +const TELEMETRY_DISABLE_HELP = '设置 CODING_TOOLS_MCP_TELEMETRY=off、DO_NOT_TRACK=1,或在 CI 环境中启动;修改后重启服务。'; + +globalThis.McpSettingsCopy = { + PERMISSION_COPY, + FAKE_READONLY_COPY, + TELEMETRY_DISABLE_HELP, + permissionPresentation, + telemetryPresentation, +}; + +export { PERMISSION_COPY, FAKE_READONLY_COPY, TELEMETRY_DISABLE_HELP, permissionPresentation, telemetryPresentation }; diff --git a/webui/src/settings-model.js b/webui/src/settings-model.js new file mode 100644 index 0000000..1049fe3 --- /dev/null +++ b/webui/src/settings-model.js @@ -0,0 +1,93 @@ +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function canonicalWorkspaces(workspaces, defaultId) { + const rows = Array.isArray(workspaces) ? workspaces.map((item) => ({ ...item })) : []; + let resolved = defaultId || rows.find((item) => item.default)?.id || rows.find((item) => item.enabled !== false)?.id || rows[0]?.id || ''; + if (resolved && !rows.some((item) => item.id === resolved && item.enabled !== false)) { + resolved = rows.find((item) => item.enabled !== false)?.id || ''; + } + return rows.map((item) => ({ ...item, default: Boolean(resolved && item.id === resolved) })); +} + +function canonicalSettings(settings = {}) { + const result = clone(settings) || {}; + const catalog = canonicalWorkspaces(result.workspace_catalog, result.default_workspace_id); + if (catalog.length) { + result.workspace_catalog = catalog; + result.default_workspace_id = catalog.find((item) => item.default)?.id || ''; + result.workspace = catalog.find((item) => item.default)?.root || result.workspace || ''; + } + return result; +} + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function hydrateSettings(payload = {}) { + const active = canonicalSettings(payload.active || {}); + const persisted = canonicalSettings(payload.persisted || {}); + return { + active, + persisted, + draft: clone(persisted), + persistedRevision: String(payload.persisted_revision || ''), + pendingRestart: Array.isArray(payload.pending_restart) ? [...payload.pending_restart] : [], + restartRequired: Boolean(payload.restart_required), + conflict: null, + schema: clone(payload.schema || {}), + }; +} + +function refreshPersistedKeepingDraft(state, payload = {}) { + const draft = clone(state.draft); + const next = hydrateSettings(payload); + next.draft = draft; + next.conflict = { + code: 'stale_revision', + message: '服务器设置已被其他管理员更新。草稿已保留,请审阅最新 persisted revision 后再次保存。', + }; + return next; +} + +function computeDirty(draft, persisted) { + return stableJson(canonicalSettings(draft || {})) !== stableJson(canonicalSettings(persisted || {})); +} + +function serializeSettings(draft = {}) { + return canonicalSettings(draft); +} + +function createWorkspace(existing = []) { + const ids = new Set(existing.map((item) => item.id)); + let index = 1; + let id = 'ws-new'; + while (ids.has(id)) id = `ws-new-${index++}`; + return { id, name: 'New Workspace', root: '', enabled: true, default: existing.length === 0 }; +} + +function setDefaultWorkspace(workspaces, workspaceId) { + if (!workspaces.some((item) => item.id === workspaceId && item.enabled !== false)) { + throw new Error('Only an enabled Workspace can be the default.'); + } + return workspaces.map((item) => ({ ...item, default: item.id === workspaceId })); +} + +globalThis.McpSettingsModel = { + canonicalWorkspaces, + canonicalSettings, + hydrateSettings, + refreshPersistedKeepingDraft, + computeDirty, + serializeSettings, + createWorkspace, + setDefaultWorkspace, +}; + +export { canonicalWorkspaces, canonicalSettings, hydrateSettings, refreshPersistedKeepingDraft, computeDirty, serializeSettings, createWorkspace, setDefaultWorkspace }; diff --git a/webui/src/settings-page.js b/webui/src/settings-page.js new file mode 100644 index 0000000..ecaf871 --- /dev/null +++ b/webui/src/settings-page.js @@ -0,0 +1,82 @@ +function setControlValue(documentRef, id, value) { + const control = documentRef.getElementById(id); + if (!control) return; + if (control.type === 'checkbox') control.checked = Boolean(value); + else control.value = value ?? ''; +} + +function renderSettingsForm(documentRef, state, permissionPresentation) { + const draft = state?.draft || {}; + setControlValue(documentRef, 'settingsHost', draft.host || ''); + setControlValue(documentRef, 'settingsPort', draft.port || ''); + setControlValue(documentRef, 'settingsPermission', draft.permission_mode || 'safe'); + setControlValue(documentRef, 'settingsShellEnv', draft.shell_env_inherit || 'core'); + setControlValue(documentRef, 'settingsOauthServerUrl', draft.oauth_server_url || ''); + setControlValue(documentRef, 'settingsOauthCompatibility', draft.oauth_compatibility_mode || false); + setControlValue(documentRef, 'settingsAllowedOrigins', Array.isArray(draft.allowed_origins) ? draft.allowed_origins.join('\n') : ''); + const presentation = permissionPresentation(draft.permission_mode || 'safe'); + const help = documentRef.getElementById('permissionHelp'); + if (help) help.textContent = presentation.description; + const active = documentRef.getElementById('settingsActiveJson'); + if (active) active.textContent = JSON.stringify(state?.active || {}, null, 2); + const persisted = documentRef.getElementById('settingsPersistedJson'); + if (persisted) persisted.textContent = JSON.stringify(state?.persisted || {}, null, 2); + const pending = documentRef.getElementById('settingsPending'); + if (pending) { + pending.replaceChildren(); + const values = state?.pendingRestart || []; + if (!values.length) pending.append(documentRef.createTextNode('无待重启字段。')); + for (const field of values) { + const item = documentRef.createElement('li'); + item.textContent = field; + pending.append(item); + } + } + const revision = documentRef.getElementById('settingsRevision'); + if (revision) revision.textContent = state?.persistedRevision || '—'; + const conflict = documentRef.getElementById('settingsConflict'); + if (conflict) { + conflict.textContent = state?.conflict?.message || ''; + conflict.hidden = !state?.conflict; + } +} + +function collectSettingsDraft(documentRef, previous = {}) { + const allowedOrigins = String(documentRef.getElementById('settingsAllowedOrigins')?.value || '') + .split(/\r?\n|,/) + .map((item) => item.trim()) + .filter(Boolean); + return { + ...previous, + host: String(documentRef.getElementById('settingsHost')?.value || '').trim(), + port: Number(documentRef.getElementById('settingsPort')?.value || 0), + permission_mode: String(documentRef.getElementById('settingsPermission')?.value || 'safe'), + shell_env_inherit: String(documentRef.getElementById('settingsShellEnv')?.value || 'core'), + oauth_server_url: String(documentRef.getElementById('settingsOauthServerUrl')?.value || '').trim(), + oauth_compatibility_mode: Boolean(documentRef.getElementById('settingsOauthCompatibility')?.checked), + allowed_origins: allowedOrigins, + }; +} + +function renderFormError(documentRef, message, fieldId = '') { + const box = documentRef.getElementById('settingsError'); + if (box) { + box.textContent = message || ''; + box.hidden = !message; + } + for (const control of documentRef.querySelectorAll('[aria-invalid="true"]')) { + control.removeAttribute('aria-invalid'); + } + if (fieldId) { + const control = documentRef.getElementById(fieldId); + if (control) { + control.setAttribute('aria-invalid', 'true'); + control.focus(); + } + } else if (message && box) { + box.focus(); + } +} + +globalThis.McpSettingsPage = { renderSettingsForm, collectSettingsDraft, renderFormError }; +export { renderSettingsForm, collectSettingsDraft, renderFormError }; diff --git a/webui/src/workspace-editor.js b/webui/src/workspace-editor.js new file mode 100644 index 0000000..9362530 --- /dev/null +++ b/webui/src/workspace-editor.js @@ -0,0 +1,71 @@ +function element(documentRef, tag, options = {}) { + const node = documentRef.createElement(tag); + if (options.className) node.className = options.className; + if (options.text !== undefined) node.textContent = String(options.text); + if (options.type) node.type = options.type; + if (options.id) node.id = options.id; + if (options.title) node.title = options.title; + return node; +} + +function renderWorkspaceRows(container, workspaces, handlers = {}) { + const documentRef = container.ownerDocument || document; + container.replaceChildren(); + if (!Array.isArray(workspaces) || workspaces.length === 0) { + container.append(element(documentRef, 'p', { className: 'muted', text: '尚未登记 Workspace。' })); + return; + } + for (const workspace of workspaces) { + const card = element(documentRef, 'article', { className: 'workspace-card' }); + card.dataset.workspaceId = String(workspace.id || ''); + const heading = element(documentRef, 'div', { className: 'workspace-heading' }); + const title = element(documentRef, 'h3', { text: workspace.name || workspace.id || 'Unnamed Workspace' }); + const badge = element(documentRef, 'span', { + className: `badge ${workspace.default ? 'good' : workspace.enabled === false ? 'danger' : ''}`, + text: workspace.default ? '默认' : workspace.enabled === false ? '已禁用' : '已启用', + }); + heading.append(title, badge); + card.append(heading); + + const ids = element(documentRef, 'dl', { className: 'compact-definition' }); + for (const [label, value] of [['ID', workspace.id], ['Root', workspace.root]]) { + const dt = element(documentRef, 'dt', { text: label }); + const dd = element(documentRef, 'dd'); + const code = element(documentRef, 'code', { text: value || '—' }); + dd.append(code); + ids.append(dt, dd); + } + card.append(ids); + + const actions = element(documentRef, 'div', { className: 'button-row' }); + const check = element(documentRef, 'button', { type: 'button', className: 'secondary', text: '检查' }); + check.addEventListener('click', () => handlers.onCheck?.(workspace, check)); + actions.append(check); + if (!workspace.default && workspace.enabled !== false) { + const makeDefault = element(documentRef, 'button', { type: 'button', className: 'secondary', text: '设为默认' }); + makeDefault.addEventListener('click', () => handlers.onDefault?.(workspace, makeDefault)); + actions.append(makeDefault); + const disable = element(documentRef, 'button', { type: 'button', className: 'danger', text: '禁用' }); + disable.addEventListener('click', () => handlers.onDisable?.(workspace, disable)); + actions.append(disable); + } + card.append(actions); + container.append(card); + } +} + +function populateWorkspaceSelect(select, workspaces, selectedId = '') { + const documentRef = select.ownerDocument || document; + select.replaceChildren(); + for (const workspace of workspaces || []) { + if (workspace.enabled === false) continue; + const option = documentRef.createElement('option'); + option.value = String(workspace.id || ''); + option.textContent = `${workspace.name || workspace.id} (${workspace.id})`; + option.selected = option.value === selectedId; + select.append(option); + } +} + +globalThis.McpWorkspaceEditor = { renderWorkspaceRows, populateWorkspaceSelect }; +export { renderWorkspaceRows, populateWorkspaceSelect }; diff --git a/webui/tests/dom-interactions.test.mjs b/webui/tests/dom-interactions.test.mjs new file mode 100644 index 0000000..0adb3be --- /dev/null +++ b/webui/tests/dom-interactions.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + ApiError, + confirmDestructive, + createApiClient, + handleSettingsSave, + renderConversationDetail, + renderConversationItems, +} from '../src/admin.js'; +import '../src/settings-copy.js'; +import { hydrateSettings } from '../src/settings-model.js'; +import '../src/settings-page.js'; +import { renderWorkspaceRows } from '../src/workspace-editor.js'; + +class FakeClassList { + constructor(node) { this.node = node; this.values = new Set(); } + add(...values) { values.forEach((value) => this.values.add(value)); } + remove(...values) { values.forEach((value) => this.values.delete(value)); } + toggle(value, force) { + const enabled = force === undefined ? !this.values.has(value) : Boolean(force); + if (enabled) this.values.add(value); else this.values.delete(value); + return enabled; + } + contains(value) { return this.values.has(value); } +} + +class FakeNode { + constructor(documentRef, tagName = '#text', text = '') { + this.ownerDocument = documentRef; + this.tagName = tagName.toUpperCase(); + this.children = []; + this.dataset = {}; + this.attributes = new Map(); + this.listeners = new Map(); + this.classList = new FakeClassList(this); + this.className = ''; + this.disabled = false; + this.hidden = false; + this.value = ''; + this.returnValue = ''; + this._text = text; + } + get childNodes() { return this.children; } + get textContent() { return this._text + this.children.map((child) => child.textContent).join(''); } + set textContent(value) { this._text = String(value ?? ''); this.children = []; } + append(...nodes) { + for (const node of nodes) this.children.push(typeof node === 'string' ? this.ownerDocument.createTextNode(node) : node); + } + replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); } + addEventListener(type, callback) { + const values = this.listeners.get(type) || []; + values.push(callback); this.listeners.set(type, values); + } + removeEventListener(type, callback) { + this.listeners.set(type, (this.listeners.get(type) || []).filter((value) => value !== callback)); + } + dispatchEvent(event) { for (const callback of this.listeners.get(event.type) || []) callback.call(this, event); } + click() { this.dispatchEvent({ type: 'click', currentTarget: this, preventDefault() {} }); } + focus() { this.ownerDocument.activeElement = this; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + getAttribute(name) { return this.attributes.get(name) ?? null; } + removeAttribute(name) { this.attributes.delete(name); } +} + +class FakeDialog extends FakeNode { + showModal() { this.open = true; } + close(value = '') { this.returnValue = value; this.open = false; this.dispatchEvent({ type: 'close' }); } +} + +class FakeDocument { + constructor() { this.ids = new Map(); this.activeElement = null; } + createElement(tag) { return tag === 'dialog' ? new FakeDialog(this, tag) : new FakeNode(this, tag); } + createTextNode(text) { return new FakeNode(this, '#text', String(text)); } + getElementById(id) { return this.ids.get(id) || null; } + querySelectorAll() { return []; } + register(id, node) { node.id = id; this.ids.set(id, node); return node; } +} + +function tags(node) { + return [node.tagName, ...node.children.flatMap(tags)]; +} + +test('conversation summary and detail render untrusted text without creating markup', () => { + const documentRef = new FakeDocument(); + const list = new FakeNode(documentRef, 'div'); + renderConversationItems(list, [{ + workspace_id: 'ws-a', conversation_id: 'conv-a', title: '', + preview: '', message_count: 1, context_count: 1, + }], () => {}); + assert.equal(tags(list).includes('IMG'), false); + assert.equal(tags(list).includes('SCRIPT'), false); + assert.match(list.textContent, /steal/); + + const detail = new FakeNode(documentRef, 'div'); + renderConversationDetail(detail, { + conversation: { workspace_id: 'ws-a', conversation_id: 'conv-a', title: '' }, + messages: [{ message_id: 'm1', role: 'user', content: '' }], + messages_total: 1, message_page: 1, message_page_size: 50, + contexts: [{ context_id: 'c1', kind: 'note', content: '' }], + contexts_total: 1, context_page: 1, context_page_size: 50, + }); + assert.equal(tags(detail).includes('IFRAME'), false); + assert.equal(tags(detail).includes('SVG'), false); + assert.match(detail.textContent, /