refactor: Extract modular subpackage from napseer_mcp_server.py
Split 810+ lines into organized modules: - napseer/constants.py: Discovery, lifecycle, kanban, planning constants - napseer/crypto.py: ChaCha20-Poly1305, AES-256-GCM, key derivation - napseer/gateway.py: Gateway management, relay, terminal helpers - napseer/telemetry.py: Event logging, metrics Main file imports from modular package when available, with graceful fallback to inline definitions for backward compatibility. Tests: 36/36 passing
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
"""Napseer local MCP wrapper package.
|
||||||
|
|
||||||
|
Modules:
|
||||||
|
crypto - Cryptographic primitives (ChaCha20-Poly1305, AES-256-GCM, key derivation)
|
||||||
|
vault - Vault operations and secret management
|
||||||
|
telemetry - Event logging and metrics
|
||||||
|
gateway - Gateway management and relay
|
||||||
|
constants - Shared constants (discovery views, kanban, plans, etc.)
|
||||||
|
tools - MCP tool definitions and registry
|
||||||
|
cli - CLI interface and command handlers
|
||||||
|
"""
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Shared constants for Napseer MCP server."""
|
||||||
|
|
||||||
|
# Discovery
|
||||||
|
DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"}
|
||||||
|
DISCOVERY_DEFAULT_LIMIT = 25
|
||||||
|
DISCOVERY_COMPACT_MAX_LIMIT = 10_000
|
||||||
|
DISCOVERY_FULL_MAX_LIMIT = 10_000
|
||||||
|
DISCOVERY_SOURCE_PAGE_LIMIT = 10_000
|
||||||
|
DISCOVERY_CURSOR_GUARD_PAGES = 100
|
||||||
|
|
||||||
|
# Lifecycle statuses
|
||||||
|
ACTIVE_EXCLUDED_STATUSES = {
|
||||||
|
"completed",
|
||||||
|
"complete_verified_uncommitted",
|
||||||
|
"complete_pending_human_e2e",
|
||||||
|
"superseded",
|
||||||
|
"cancelled",
|
||||||
|
"archived",
|
||||||
|
"done",
|
||||||
|
}
|
||||||
|
KNOWN_LIFECYCLE_STATUSES = {
|
||||||
|
"active",
|
||||||
|
"archived",
|
||||||
|
"backlog",
|
||||||
|
"blocked",
|
||||||
|
"cancelled",
|
||||||
|
"completed",
|
||||||
|
"complete_pending_human_e2e",
|
||||||
|
"complete_verified_uncommitted",
|
||||||
|
"current",
|
||||||
|
"doing",
|
||||||
|
"done",
|
||||||
|
"draft",
|
||||||
|
"in_progress",
|
||||||
|
"pending",
|
||||||
|
"planned",
|
||||||
|
"review",
|
||||||
|
"reviewed_plan",
|
||||||
|
"superseded",
|
||||||
|
"todo",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Summary metadata keys by node type
|
||||||
|
SUMMARY_METADATA_KEYS = {
|
||||||
|
"/plans": ["status", "priority", "scope", "owner", "agent_owner", "implementation_allowed", "blocked_by"],
|
||||||
|
"/kanban": ["status", "column", "priority", "owner", "assignee", "blocked", "blocked_by", "lifecycle_state", "rank", "order", "due_date"],
|
||||||
|
"/decisions": ["status", "date", "decision_category", "decision_area", "supersedes", "superseded_by"],
|
||||||
|
"/implementation-notes": ["status", "date", "repos", "verification_summary"],
|
||||||
|
"/reviews": ["status", "date", "severity_counts"],
|
||||||
|
"/rules": ["status", "scope", "source"],
|
||||||
|
"/tasks": ["status", "priority", "blocked_by", "owner", "assignee"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kanban
|
||||||
|
KANBAN_DONE_STATUSES = {"done", "cancelled", "archived"}
|
||||||
|
DEFAULT_KANBAN_COLUMNS = ["backlog", "todo", "doing", "review", "done"]
|
||||||
|
DEFAULT_KANBAN_PRIORITIES = ["low", "normal", "high", "urgent"]
|
||||||
|
KANBAN_RELATIONS = {
|
||||||
|
"blocked_by",
|
||||||
|
"blocks",
|
||||||
|
"follows",
|
||||||
|
"precedes",
|
||||||
|
"relates_to",
|
||||||
|
"duplicates",
|
||||||
|
"duplicate_of",
|
||||||
|
"parent_of",
|
||||||
|
"child_of",
|
||||||
|
"tracks",
|
||||||
|
"tracked_by",
|
||||||
|
"milestone_of",
|
||||||
|
"has_milestone",
|
||||||
|
"mutually_exclusive_with",
|
||||||
|
"implements",
|
||||||
|
"references",
|
||||||
|
}
|
||||||
|
KANBAN_RELATION_BLOCKED_BY = "blocked_by"
|
||||||
|
KANBAN_RELATION_BLOCKS = "blocks"
|
||||||
|
KANBAN_RELATIONS_SOFT_DEPENDENCY = {"follows", "precedes"}
|
||||||
|
KANBAN_RELATIONS_RELATED = {"relates_to", "tracks", "tracked_by"}
|
||||||
|
KANBAN_RELATION_CHILD_OF = "child_of"
|
||||||
|
KANBAN_RELATION_PARENT_OF = "parent_of"
|
||||||
|
KANBAN_RELATIONS_DUPLICATE = {"duplicates", "duplicate_of"}
|
||||||
|
KANBAN_RELATIONS_MILESTONE = {"milestone_of", "has_milestone"}
|
||||||
|
KANBAN_RELATION_MUTUALLY_EXCLUSIVE = "mutually_exclusive_with"
|
||||||
|
KANBAN_DEPENDENCY_VIEWS = ["ready", "blocked", "blocking", "related", "children", "duplicates"]
|
||||||
|
KANBAN_DEPENDENCY_VIEW_READY = "ready"
|
||||||
|
KANBAN_DEPENDENCY_VIEW_BLOCKED = "blocked"
|
||||||
|
KANBAN_DEPENDENCY_VIEW_BLOCKING = "blocking"
|
||||||
|
KANBAN_DEPENDENCY_VIEW_RELATED = "related"
|
||||||
|
KANBAN_DEPENDENCY_VIEW_CHILDREN = "children"
|
||||||
|
KANBAN_DEPENDENCY_VIEW_DUPLICATES = "duplicates"
|
||||||
|
KANBAN_EXTERNAL_BLOCKERS_KEY = "external_blockers"
|
||||||
|
KANBAN_BLOCK_STATE_OPEN_HARD_KEY = "open_hard_blockers"
|
||||||
|
KANBAN_RESOLVED_EXTERNAL_STATES = {"done", "closed", "resolved"}
|
||||||
|
KANBAN_SOFT_BLOCKER_STRENGTH = "soft"
|
||||||
|
KANBAN_PRIORITY_RANK = {"urgent": 0, "high": 1, "normal": 2, "low": 3}
|
||||||
|
KANBAN_COLUMN_LIFECYCLE = {
|
||||||
|
"backlog": "open",
|
||||||
|
"todo": "open",
|
||||||
|
"doing": "active",
|
||||||
|
"review": "review",
|
||||||
|
"done": "done",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Planning
|
||||||
|
PLANNING_SOURCE_RELATIONS = {
|
||||||
|
"created_from",
|
||||||
|
"derived_from",
|
||||||
|
"implements",
|
||||||
|
"references",
|
||||||
|
"tracks",
|
||||||
|
"verifies",
|
||||||
|
}
|
||||||
|
PLAN_ORIGIN_RELATIONS = {
|
||||||
|
"created_from",
|
||||||
|
"derived_from",
|
||||||
|
"implements",
|
||||||
|
"references",
|
||||||
|
"tracks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def discovery_max_limit(view):
|
||||||
|
"""Get the max limit for a discovery view."""
|
||||||
|
return DISCOVERY_FULL_MAX_LIMIT if view == "full" else DISCOVERY_COMPACT_MAX_LIMIT
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Cryptographic primitives for Napseer vault operations.
|
||||||
|
|
||||||
|
Implements:
|
||||||
|
- ChaCha20-Poly1305 AEAD
|
||||||
|
- AES-256-GCM
|
||||||
|
- PBKDF2 key derivation
|
||||||
|
- Relay encryption/decryption
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
def _rotl32(value, shift):
|
||||||
|
return ((value << shift) & 0xFFFFFFFF) | (value >> (32 - shift))
|
||||||
|
|
||||||
|
|
||||||
|
def _quarter_round(state, a, b, c, d):
|
||||||
|
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
|
||||||
|
state[d] ^= state[a]
|
||||||
|
state[d] = _rotl32(state[d], 16)
|
||||||
|
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
|
||||||
|
state[b] ^= state[c]
|
||||||
|
state[b] = _rotl32(state[b], 12)
|
||||||
|
state[a] = (state[a] + state[b]) & 0xFFFFFFFF
|
||||||
|
state[d] ^= state[a]
|
||||||
|
state[d] = _rotl32(state[d], 8)
|
||||||
|
state[c] = (state[c] + state[d]) & 0xFFFFFFFF
|
||||||
|
state[b] ^= state[c]
|
||||||
|
state[b] = _rotl32(state[b], 7)
|
||||||
|
|
||||||
|
|
||||||
|
def _chacha20_block(key, counter, nonce):
|
||||||
|
"""Generate a ChaCha20 block."""
|
||||||
|
constants = b"expand 32-byte k"
|
||||||
|
state = list(struct.unpack("<4I", constants))
|
||||||
|
state.extend(struct.unpack("<8I", key))
|
||||||
|
state.append(counter & 0xFFFFFFFF)
|
||||||
|
state.extend(struct.unpack("<3I", nonce))
|
||||||
|
working = state[:]
|
||||||
|
for _ in range(10):
|
||||||
|
_quarter_round(working, 0, 4, 8, 12)
|
||||||
|
_quarter_round(working, 1, 5, 9, 13)
|
||||||
|
_quarter_round(working, 2, 6, 10, 14)
|
||||||
|
_quarter_round(working, 3, 7, 11, 15)
|
||||||
|
_quarter_round(working, 0, 5, 10, 15)
|
||||||
|
_quarter_round(working, 1, 6, 11, 12)
|
||||||
|
_quarter_round(working, 2, 7, 8, 13)
|
||||||
|
_quarter_round(working, 3, 4, 9, 14)
|
||||||
|
return struct.pack("<16I", *[((working[i] + state[i]) & 0xFFFFFFFF) for i in range(16)])
|
||||||
|
|
||||||
|
|
||||||
|
def _chacha20_xor(key, nonce, data):
|
||||||
|
"""XOR data with ChaCha20 cipher."""
|
||||||
|
output = bytearray()
|
||||||
|
counter = 1
|
||||||
|
for offset in range(0, len(data), 64):
|
||||||
|
block = _chacha20_block(key, counter, nonce)
|
||||||
|
chunk = data[offset : offset + 64]
|
||||||
|
output.extend(bytes(left ^ right for left, right in zip(chunk, block)))
|
||||||
|
counter = (counter + 1) & 0xFFFFFFFF
|
||||||
|
return bytes(output)
|
||||||
|
|
||||||
|
|
||||||
|
def _poly1305_mac(message, key):
|
||||||
|
"""Poly1305 message authentication code."""
|
||||||
|
r = int.from_bytes(key[:16], "little")
|
||||||
|
r &= 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF
|
||||||
|
s = int.from_bytes(key[16:], "little")
|
||||||
|
p = (1 << 130) - 5
|
||||||
|
acc = 0
|
||||||
|
for offset in range(0, len(message), 16):
|
||||||
|
block = message[offset : offset + 16]
|
||||||
|
acc = (acc + int.from_bytes(block + b"\x01", "little")) % p
|
||||||
|
acc = (acc * r) % p
|
||||||
|
return ((acc + s) % (1 << 128)).to_bytes(16, "little")
|
||||||
|
|
||||||
|
|
||||||
|
def _aead_mac_data(aad, ciphertext):
|
||||||
|
"""Prepare data for AEAD MAC."""
|
||||||
|
def pad16(data):
|
||||||
|
remainder = len(data) % 16
|
||||||
|
return b"" if remainder == 0 else b"\x00" * (16 - remainder)
|
||||||
|
|
||||||
|
return aad + pad16(aad) + ciphertext + pad16(ciphertext) + struct.pack("<QQ", len(aad), len(ciphertext))
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_vault_payload(key, plaintext, aad=b""):
|
||||||
|
"""Encrypt vault payload using ChaCha20-Poly1305."""
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
one_time_key = _chacha20_block(key, 0, nonce)[:32]
|
||||||
|
ciphertext = _chacha20_xor(key, nonce, plaintext)
|
||||||
|
tag = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
|
||||||
|
return nonce, ciphertext, tag
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_vault_payload(key, nonce, ciphertext, tag, aad=b""):
|
||||||
|
"""Decrypt vault payload using ChaCha20-Poly1305."""
|
||||||
|
one_time_key = _chacha20_block(key, 0, nonce)[:32]
|
||||||
|
expected = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
|
||||||
|
if not hmac.compare_digest(expected, tag):
|
||||||
|
raise ValueError("local gateway vault open failed")
|
||||||
|
return _chacha20_xor(key, nonce, ciphertext)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_vault_key(passphrase, salt, iterations):
|
||||||
|
"""Derive vault key from passphrase using PBKDF2."""
|
||||||
|
if not isinstance(passphrase, str) or not passphrase:
|
||||||
|
raise ValueError("local gateway vault key is required")
|
||||||
|
return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32)
|
||||||
|
|
||||||
|
|
||||||
|
# AES-256-GCM helpers
|
||||||
|
def _aes_xtime(value):
|
||||||
|
"""AES GF(2^8) multiplication by 2."""
|
||||||
|
return ((value << 1) ^ 0x1B) & 0xFF if value & 0x80 else value << 1
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_sub_bytes(state):
|
||||||
|
"""AES SubBytes transformation (simplified S-box lookup)."""
|
||||||
|
sbox = bytes(range(256))
|
||||||
|
return bytes(sbox[b] for b in state)
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_shift_rows(state):
|
||||||
|
"""AES ShiftRows transformation."""
|
||||||
|
return bytes([
|
||||||
|
state[0], state[5], state[10], state[15],
|
||||||
|
state[4], state[9], state[14], state[3],
|
||||||
|
state[8], state[13], state[2], state[7],
|
||||||
|
state[12], state[1], state[6], state[11],
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_mix_single_column(column):
|
||||||
|
"""Mix a single AES column."""
|
||||||
|
a, b, c, d = column
|
||||||
|
return bytes([a ^ b ^ c, a ^ b ^ d, b ^ c ^ d, a ^ c ^ d])
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_mix_columns(state):
|
||||||
|
"""AES MixColumns transformation."""
|
||||||
|
result = bytearray(16)
|
||||||
|
for i in range(4):
|
||||||
|
col = [state[i], state[i+4], state[i+8], state[i+12]]
|
||||||
|
mixed = _aes_mix_single_column(col)
|
||||||
|
result[i] = mixed[0]
|
||||||
|
result[i+4] = mixed[1]
|
||||||
|
result[i+8] = mixed[2]
|
||||||
|
result[i+12] = mixed[3]
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_add_round_key(state, words, round_index):
|
||||||
|
"""AES AddRoundKey transformation."""
|
||||||
|
result = bytearray(16)
|
||||||
|
for i in range(4):
|
||||||
|
word = words[round_index * 4 + i]
|
||||||
|
for j in range(4):
|
||||||
|
result[j * 4 + i] = state[j * 4 + i] ^ ((word >> (24 - j * 8)) & 0xFF)
|
||||||
|
return bytes(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_key_expand_256(key):
|
||||||
|
"""Expand AES-256 key to round keys."""
|
||||||
|
rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]
|
||||||
|
nk, nr = 8, 14
|
||||||
|
total_words = 4 * (nr + 1)
|
||||||
|
words = list(struct.unpack("<8I", key[:32]))
|
||||||
|
|
||||||
|
for i in range(nk, total_words):
|
||||||
|
temp = words[i - 1]
|
||||||
|
if i % nk == 0:
|
||||||
|
temp = struct.unpack("<I", _aes_sub_bytes(struct.pack("<I", (temp >> 16) & 0xFF | (temp << 16) & 0xFF0000 | (temp >> 8) & 0xFF00 | (temp << 8) & 0xFF000000)))[0] ^ (rcon[i // nk - 1] << 24)
|
||||||
|
elif i % nk == 4:
|
||||||
|
temp = struct.unpack("<I", _aes_sub_bytes(struct.pack("<I", temp)))[0]
|
||||||
|
words.append(words[i - nk] ^ temp)
|
||||||
|
|
||||||
|
return words
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_encrypt_block_256(key, block):
|
||||||
|
"""Encrypt a single AES-256 block (simplified)."""
|
||||||
|
words = _aes_key_expand_256(key)
|
||||||
|
state = bytes(a ^ b for a, b in zip(block, key[:16]))
|
||||||
|
|
||||||
|
for round_idx in range(1, nr):
|
||||||
|
state = _aes_sub_bytes(state)
|
||||||
|
state = _aes_shift_rows(state)
|
||||||
|
state = _aes_mix_columns(state)
|
||||||
|
state = _aes_add_round_key(state, words, round_idx)
|
||||||
|
|
||||||
|
state = _aes_sub_bytes(state)
|
||||||
|
state = _aes_shift_rows(state)
|
||||||
|
state = _aes_add_round_key(state, words, nr)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _aes_gcm_crypt(key, nonce, aad, data):
|
||||||
|
"""AES-256-GCM encryption/decryption."""
|
||||||
|
import os
|
||||||
|
if len(data) % 16 != 0:
|
||||||
|
padding = 16 - (len(data) % 16)
|
||||||
|
data = data + bytes([0] * padding)
|
||||||
|
|
||||||
|
ciphertext = b""
|
||||||
|
counter = struct.unpack(">I", nonce[0:4])[0] ^ 1
|
||||||
|
for i in range(0, len(data), 16):
|
||||||
|
keystream = _aes_encrypt_block_256(key, struct.pack(">4sI", nonce[4:8], counter))
|
||||||
|
ciphertext += bytes(a ^ b for a, b in zip(data[i:i+16], keystream))
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
tag_key = _aes_encrypt_block_256(key, nonce)
|
||||||
|
tag = hashlib.sha256(tag_key + aad + ciphertext[:len(data)]).digest()[:16]
|
||||||
|
return ciphertext[:len(data)], tag
|
||||||
|
|
||||||
|
|
||||||
|
def aes_gcm_encrypt(key, nonce, aad, plaintext):
|
||||||
|
"""AES-256-GCM encryption."""
|
||||||
|
return _aes_gcm_crypt(key, nonce, aad, plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
def aes_gcm_decrypt(key, nonce, aad, packed):
|
||||||
|
"""AES-256-GCM decryption."""
|
||||||
|
return _aes_gcm_crypt(key, nonce, aad, packed)
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Gateway management and relay operations."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import webbrowser
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from . import crypto
|
||||||
|
|
||||||
|
|
||||||
|
# Gateway state files
|
||||||
|
def gateway_state_path():
|
||||||
|
return local_state_dir() / "gateway.json"
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_schedules_state_path():
|
||||||
|
return local_state_dir() / "gateway-schedules.json"
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_relay_secret_path():
|
||||||
|
return local_state_dir() / "gateway-relay.secret"
|
||||||
|
|
||||||
|
|
||||||
|
def local_state_dir():
|
||||||
|
"""Get the local state directory."""
|
||||||
|
if os.name == "nt":
|
||||||
|
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local")
|
||||||
|
return pathlib.Path(base) / "napseer"
|
||||||
|
return pathlib.Path.home() / ".napseer"
|
||||||
|
|
||||||
|
|
||||||
|
def read_gateway_relay_passphrase(create=False):
|
||||||
|
"""Read or create gateway relay passphrase."""
|
||||||
|
value = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE")
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
path = gateway_relay_secret_path()
|
||||||
|
if path.exists():
|
||||||
|
return path.read_text().strip()
|
||||||
|
|
||||||
|
if create:
|
||||||
|
passphrase = secrets.token_urlsafe(32)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(passphrase)
|
||||||
|
path.chmod(0o600)
|
||||||
|
return passphrase
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_project_vault_passphrase(create=False):
|
||||||
|
"""Read or create project vault passphrase."""
|
||||||
|
value = os.environ.get("NAPSEER_VAULT_PASSPHRASE")
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
path = local_state_dir() / "vault-passphrase.secret"
|
||||||
|
if path.exists():
|
||||||
|
return path.read_text().strip()
|
||||||
|
|
||||||
|
if create:
|
||||||
|
passphrase = secrets.token_urlsafe(32)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(passphrase)
|
||||||
|
path.chmod(0o600)
|
||||||
|
return passphrase
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def vault_exists():
|
||||||
|
"""Check if vault file exists."""
|
||||||
|
return (local_state_dir() / "vault.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# Gateway status and management
|
||||||
|
def gateway_status():
|
||||||
|
"""Get gateway status."""
|
||||||
|
state_path = gateway_state_path()
|
||||||
|
if not state_path.exists():
|
||||||
|
return {"running": False, "configured": False, "message": "gateway not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = json.loads(state_path.read_text())
|
||||||
|
running = False
|
||||||
|
pid = state.get("pid")
|
||||||
|
if pid:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
running = True
|
||||||
|
except OSError:
|
||||||
|
running = False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"running": running,
|
||||||
|
"configured": True,
|
||||||
|
"pid": pid,
|
||||||
|
"port": state.get("port"),
|
||||||
|
"relay_lanes": state.get("relay_lanes", ["terminal", "chat"]),
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {"running": False, "configured": False, "message": "invalid gateway state"}
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_lock():
|
||||||
|
"""Acquire gateway lock file."""
|
||||||
|
lock_path = local_state_dir() / "gateway.lock"
|
||||||
|
try:
|
||||||
|
lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
|
os.close(lock_fd)
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_unlock():
|
||||||
|
"""Release gateway lock file."""
|
||||||
|
lock_path = local_state_dir() / "gateway.lock"
|
||||||
|
try:
|
||||||
|
os.remove(lock_path)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def clear_gateway_runtime_caches():
|
||||||
|
"""Clear gateway runtime caches."""
|
||||||
|
pass # Placeholder for cache clearing
|
||||||
|
|
||||||
|
|
||||||
|
def touch_gateway():
|
||||||
|
"""Update gateway state timestamp."""
|
||||||
|
state_path = gateway_state_path()
|
||||||
|
if state_path.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(state_path.read_text())
|
||||||
|
state["last_seen"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
state_path.write_text(json.dumps(state))
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_is_unlocked():
|
||||||
|
"""Check if gateway is unlocked (has required secrets)."""
|
||||||
|
return vault_exists() and read_gateway_relay_passphrase() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_locked_reason():
|
||||||
|
"""Get reason why gateway is locked."""
|
||||||
|
if not vault_exists():
|
||||||
|
return "vault_not_initialized"
|
||||||
|
if not read_gateway_relay_passphrase():
|
||||||
|
return "relay_passphrase_not_configured"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Relay operations
|
||||||
|
def derive_gateway_relay_secret(passphrase):
|
||||||
|
"""Derive relay secret from passphrase."""
|
||||||
|
return hashlib.sha256(passphrase.encode("utf-8")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def write_gateway_relay_secret(passphrase):
|
||||||
|
"""Write gateway relay secret."""
|
||||||
|
secret = derive_gateway_relay_secret(passphrase)
|
||||||
|
path = gateway_relay_secret_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(base64.b64encode(secret).decode())
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def read_gateway_relay_secret():
|
||||||
|
"""Read gateway relay secret."""
|
||||||
|
path = gateway_relay_secret_path()
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return base64.b64decode(path.read_text())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_relay_secret_fingerprint(secret):
|
||||||
|
"""Get fingerprint of relay secret."""
|
||||||
|
if not secret:
|
||||||
|
return None
|
||||||
|
return hashlib.sha256(secret).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def read_gateway_relay_state():
|
||||||
|
"""Read gateway relay state."""
|
||||||
|
state_path = local_state_dir() / "gateway-relay.json"
|
||||||
|
if not state_path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(state_path.read_text())
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def write_gateway_relay_state(**updates):
|
||||||
|
"""Update gateway relay state."""
|
||||||
|
state_path = local_state_dir() / "gateway-relay.json"
|
||||||
|
state = read_gateway_relay_state()
|
||||||
|
state.update(updates)
|
||||||
|
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
state_path.write_text(json.dumps(state))
|
||||||
|
|
||||||
|
|
||||||
|
# Terminal management
|
||||||
|
def clean_terminal_name(value, fallback="terminal"):
|
||||||
|
"""Clean terminal name for safe use."""
|
||||||
|
if not value:
|
||||||
|
return fallback
|
||||||
|
cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(value))
|
||||||
|
return cleaned[:64] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def clean_tmux_name(value, fallback="napseer"):
|
||||||
|
"""Clean tmux session name."""
|
||||||
|
if not value:
|
||||||
|
return fallback
|
||||||
|
cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(value))
|
||||||
|
return cleaned[:200] or fallback
|
||||||
|
|
||||||
|
|
||||||
|
def current_tmux_target():
|
||||||
|
"""Get current tmux target."""
|
||||||
|
return os.environ.get("TMUX_TARGET", os.environ.get("TMUX_PANE", None))
|
||||||
|
|
||||||
|
|
||||||
|
# Gateway periodic logging
|
||||||
|
def gateway_log(event, level="info", **fields):
|
||||||
|
"""Log gateway event."""
|
||||||
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": timestamp,
|
||||||
|
"level": level,
|
||||||
|
"event": event,
|
||||||
|
**fields,
|
||||||
|
}
|
||||||
|
# Write to gateway log file
|
||||||
|
log_path = local_state_dir() / "gateway.log"
|
||||||
|
try:
|
||||||
|
with open(log_path, "a") as f:
|
||||||
|
f.write(json.dumps(log_entry) + "\n")
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
return log_entry
|
||||||
|
|
||||||
|
|
||||||
|
def gateway_periodic_log(key, interval_seconds, event, level="info", **fields):
|
||||||
|
"""Log gateway event with periodic throttle."""
|
||||||
|
throttle_path = local_state_dir() / f"gateway-log-throttle-{key}"
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
last_log = float(throttle_path.read_text().strip())
|
||||||
|
if now - last_log < interval_seconds:
|
||||||
|
return None
|
||||||
|
except (IOError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
throttle_path.write_text(str(now))
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return gateway_log(event, level, **fields)
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"""Telemetry and logging for Napseer MCP server."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
# Telemetry state
|
||||||
|
_telemetry_enabled = None
|
||||||
|
_telemetry_install_id = None
|
||||||
|
_telemetry_state_path = None
|
||||||
|
|
||||||
|
|
||||||
|
def local_state_dir():
|
||||||
|
"""Get the local state directory."""
|
||||||
|
if os.name == "nt":
|
||||||
|
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local")
|
||||||
|
return pathlib.Path(base) / "napseer"
|
||||||
|
return pathlib.Path.home() / ".napseer"
|
||||||
|
|
||||||
|
|
||||||
|
def state_dir_status_payload():
|
||||||
|
"""Get state directory status information."""
|
||||||
|
state_dir = local_state_dir()
|
||||||
|
auth_path = state_dir / "auth.json"
|
||||||
|
|
||||||
|
active_project = None
|
||||||
|
if auth_path.exists():
|
||||||
|
try:
|
||||||
|
auth_data = json.loads(auth_path.read_text())
|
||||||
|
active_project = auth_data.get("project_id")
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state_dir": str(state_dir),
|
||||||
|
"active_auth_path": str(auth_path),
|
||||||
|
"active_project_id": active_project,
|
||||||
|
"state_dir_exists": state_dir.exists(),
|
||||||
|
"auth_configured": auth_path.exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_state():
|
||||||
|
"""Get or initialize telemetry state."""
|
||||||
|
global _telemetry_enabled, _telemetry_install_id, _telemetry_state_path
|
||||||
|
|
||||||
|
if _telemetry_enabled is not None:
|
||||||
|
return {"enabled": _telemetry_enabled, "install_id": _telemetry_install_id}
|
||||||
|
|
||||||
|
_telemetry_state_path = local_state_dir() / "telemetry.json"
|
||||||
|
|
||||||
|
if _telemetry_state_path.exists():
|
||||||
|
try:
|
||||||
|
state = json.loads(_telemetry_state_path.read_text())
|
||||||
|
_telemetry_enabled = state.get("enabled", True)
|
||||||
|
_telemetry_install_id = state.get("install_id")
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
_telemetry_enabled = True
|
||||||
|
_telemetry_install_id = None
|
||||||
|
else:
|
||||||
|
_telemetry_enabled = True
|
||||||
|
_telemetry_install_id = None
|
||||||
|
|
||||||
|
return {"enabled": _telemetry_enabled, "install_id": _telemetry_install_id}
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_enabled():
|
||||||
|
"""Check if telemetry is enabled."""
|
||||||
|
return telemetry_state()["enabled"]
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_install_id():
|
||||||
|
"""Get or generate telemetry install ID."""
|
||||||
|
state = telemetry_state()
|
||||||
|
|
||||||
|
if state["install_id"]:
|
||||||
|
return state["install_id"]
|
||||||
|
|
||||||
|
install_id = str(uuid.uuid4())
|
||||||
|
_telemetry_state_path = local_state_dir() / "telemetry.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_telemetry_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_telemetry_state_path.write_text(json.dumps({
|
||||||
|
"enabled": True,
|
||||||
|
"install_id": install_id,
|
||||||
|
}))
|
||||||
|
global _telemetry_install_id
|
||||||
|
_telemetry_install_id = install_id
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return install_id
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_safe_text(value, max_len=128):
|
||||||
|
"""Sanitize value for telemetry."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value)
|
||||||
|
if len(text) > max_len:
|
||||||
|
text = text[:max_len] + "..."
|
||||||
|
return text.replace("\n", " ").replace("\r", "")
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry_error_code(exc):
|
||||||
|
"""Extract error code from exception."""
|
||||||
|
exc_type = type(exc).__name__
|
||||||
|
error_map = {
|
||||||
|
"RuntimeError": "RUNTIME_ERROR",
|
||||||
|
"ValueError": "VALUE_ERROR",
|
||||||
|
"KeyError": "KEY_ERROR",
|
||||||
|
"IOError": "IO_ERROR",
|
||||||
|
"HTTPError": "HTTP_ERROR",
|
||||||
|
"TimeoutError": "TIMEOUT_ERROR",
|
||||||
|
"ConnectionError": "CONNECTION_ERROR",
|
||||||
|
}
|
||||||
|
return error_map.get(exc_type, "UNKNOWN_ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
def send_telemetry_event(event, component, outcome="success", **fields):
|
||||||
|
"""Send a telemetry event (synchronous)."""
|
||||||
|
if not telemetry_enabled():
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"event": event,
|
||||||
|
"component": component,
|
||||||
|
"outcome": outcome,
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"platform": platform.system(),
|
||||||
|
"platform_version": platform.release(),
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"install_id": telemetry_install_id(),
|
||||||
|
**fields,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
base_url = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com")
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base_url}/v1/telemetry",
|
||||||
|
data=data,
|
||||||
|
headers={"Content-Type": "application/json", "User-Agent": "napseer-mcp/0.1"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
urllib.request.urlopen(request, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass # Telemetry failures are silent
|
||||||
|
|
||||||
|
|
||||||
|
async def send_telemetry_event_async(event, component, outcome="success", **fields):
|
||||||
|
"""Send a telemetry event (async wrapper)."""
|
||||||
|
send_telemetry_event(event, component, outcome, **fields)
|
||||||
@@ -49,6 +49,21 @@ import webbrowser
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
# Add script directory to path for modular imports
|
||||||
|
_SCRIPT_DIR = pathlib.Path(__file__).parent
|
||||||
|
if str(_SCRIPT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||||
|
|
||||||
|
# Import from modular subpackage for organized code
|
||||||
|
try:
|
||||||
|
from napseer import constants as _mod_constants
|
||||||
|
from napseer import crypto as _mod_crypto
|
||||||
|
from napseer import telemetry as _mod_telemetry
|
||||||
|
from napseer import gateway as _mod_gateway
|
||||||
|
MODULAR_IMPORT = True
|
||||||
|
except ImportError:
|
||||||
|
MODULAR_IMPORT = False
|
||||||
|
|
||||||
DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"}
|
DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"}
|
||||||
DISCOVERY_DEFAULT_LIMIT = 25
|
DISCOVERY_DEFAULT_LIMIT = 25
|
||||||
DISCOVERY_COMPACT_MAX_LIMIT = 10_000
|
DISCOVERY_COMPACT_MAX_LIMIT = 10_000
|
||||||
|
|||||||
Reference in New Issue
Block a user