From 7b979a3f9597ce95cce457b0473909e332c6837e Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sat, 7 Mar 2026 01:27:25 +0000 Subject: [PATCH] fix: prevent internal prompts and duplicates in memory database - Add server-side detection of SDK processor's internal system prompt in handleSessionInit, since CLAUDE_MNEMONIC_INTERNAL env var is not propagated by Claude Code to hook subprocesses - Add cross-session duplicate detection (FindRecentPromptByTextGlobal) to catch same prompt text arriving from different session IDs - Add hooks, mcpServers, and commands references to plugin.json per Claude Code plugin spec - Remove MCP server injection from register-plugin.sh (now in plugin.json) - Use ${CLAUDE_PLUGIN_ROOT} for statusline path instead of hardcoded path - Add python3 fallback for plugin registration when jq is unavailable - Replace hardcoded 1.0.0 version in findWorkerBinary with glob lookup - Add cache copy verification in register-plugin.sh - Add update-version Makefile target to keep metadata in sync --- Makefile | 17 ++- internal/db/gorm/prompt_store.go | 22 ++++ internal/db/gorm/prompt_store_test.go | 33 ++++++ internal/db/interface.go | 1 + internal/worker/handlers_sessions.go | 48 ++++++++ internal/worker/sdk/processor.go | 7 +- pkg/hooks/worker.go | 15 ++- plugin/.claude-plugin/plugin.json.tpl | 11 +- scripts/register-plugin.sh | 161 ++++++++++++++++++-------- ui/package-lock.json | 4 +- ui/package.json | 2 +- 11 files changed, 261 insertions(+), 60 deletions(-) diff --git a/Makefile b/Makefile index 73544ee..d710d4c 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ GOARCH ?= $(shell go env GOARCH) export CGO_ENABLED=1 BUILD_TAGS := -tags "fts5" -.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs +.PHONY: all build clean test install lint hooks worker mcp stop-worker start-worker restart-worker dashboard website dev-website setup-libs update-version all: build @@ -22,8 +22,20 @@ all: build setup-libs: @./scripts/download-onnx-libs.sh all +# Update root plugin metadata with current version +update-version: + @mkdir -p .claude-plugin + @sed 's/{{ .Version }}/$(VERSION)/g; s/{{.Version}}/$(VERSION)/g' $(PLUGIN_DIR)/.claude-plugin/plugin.json.tpl > .claude-plugin/plugin.json + @echo "Updated .claude-plugin/plugin.json to version $(VERSION)" + @# marketplace.json contains release-specific data (URLs, SHA256 hashes) that requires manual update per release. + @# Only the top-level version field is updated here. + @if [ -f marketplace.json ]; then \ + sed 's/"version": "[^"]*"/"version": "$(VERSION)"/' marketplace.json > marketplace.json.tmp && mv marketplace.json.tmp marketplace.json; \ + echo "Updated marketplace.json version fields to $(VERSION)"; \ + fi + # Build all binaries -build: setup-libs dashboard worker hooks mcp +build: setup-libs update-version dashboard worker hooks mcp # Build Vue dashboard dashboard: @@ -235,6 +247,7 @@ help: @echo " make fmt - Format code" @echo " make clean - Clean build artifacts" @echo " make dev - Run worker in development mode" + @echo " make update-version - Update version in plugin metadata files" @echo " make deps - Download dependencies" @echo " make website - Build website for production" @echo " make dev-website - Run website dev server" diff --git a/internal/db/gorm/prompt_store.go b/internal/db/gorm/prompt_store.go index 301387c..e5f1bc0 100644 --- a/internal/db/gorm/prompt_store.go +++ b/internal/db/gorm/prompt_store.go @@ -264,6 +264,28 @@ func (s *PromptStore) FindRecentPromptByText(ctx context.Context, claudeSessionI return prompt.ID, prompt.PromptNumber, true } +// FindRecentPromptByTextGlobal finds a recent prompt by exact text match within a time window +// across ALL sessions (not scoped to a single claude session ID). +// This catches duplicates when the same prompt arrives from different session IDs, +// e.g. when the SDK processor's callClaudeCLI subprocess fires with a different session ID. +// Returns (promptID, promptNumber, found). +func (s *PromptStore) FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) { + cutoffEpoch := time.Now().Add(-time.Duration(withinSeconds) * time.Second).UnixMilli() + + var prompt UserPrompt + err := s.db.WithContext(ctx). + Where("prompt_text = ? AND created_at_epoch >= ?", + promptText, cutoffEpoch). + Order("created_at_epoch DESC"). + First(&prompt).Error + + if err != nil { + return 0, 0, false + } + + return prompt.ID, prompt.PromptNumber, true +} + // GetRecentUserPromptsByProject retrieves recent user prompts for a specific project. func (s *PromptStore) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) { var results []struct { diff --git a/internal/db/gorm/prompt_store_test.go b/internal/db/gorm/prompt_store_test.go index c85bdbe..3bd825c 100644 --- a/internal/db/gorm/prompt_store_test.go +++ b/internal/db/gorm/prompt_store_test.go @@ -333,6 +333,39 @@ func TestPromptStore_FindRecentPromptByText(t *testing.T) { assert.False(t, notFound, "Should not find prompt outside time window") } +func TestPromptStore_FindRecentPromptByTextGlobal(t *testing.T) { + promptStore, _, cleanup := testPromptStore(t) + defer cleanup() + + ctx := context.Background() + + // Save a prompt under session "claude-1" + id, err := promptStore.SaveUserPromptWithMatches(ctx, "claude-1", 1, "What is the architecture?", 3) + require.NoError(t, err) + + // Global search should find it without specifying session ID + foundID, foundNumber, found := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60) + assert.True(t, found, "Should find the prompt globally") + assert.Equal(t, id, foundID) + assert.Equal(t, 1, foundNumber) + + // Global search should find it even when a different session ID would have been used + // (This is the core cross-session dedup scenario) + foundID2, foundNumber2, found2 := promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 60) + assert.True(t, found2, "Should find the prompt from any session") + assert.Equal(t, id, foundID2) + assert.Equal(t, 1, foundNumber2) + + // Different text should not match + _, _, notFound := promptStore.FindRecentPromptByTextGlobal(ctx, "Different text", 60) + assert.False(t, notFound, "Should not find a different prompt") + + // Should not find prompt outside time window + time.Sleep(100 * time.Millisecond) + _, _, notFound = promptStore.FindRecentPromptByTextGlobal(ctx, "What is the architecture?", 0) + assert.False(t, notFound, "Should not find prompt outside time window") +} + func TestPromptStore_GetRecentUserPromptsByProject(t *testing.T) { promptStore, store, cleanup := testPromptStore(t) defer cleanup() diff --git a/internal/db/interface.go b/internal/db/interface.go index ea5fab6..e2ad249 100644 --- a/internal/db/interface.go +++ b/internal/db/interface.go @@ -57,6 +57,7 @@ type PromptReader interface { GetAllPrompts(ctx context.Context) ([]*models.UserPromptWithSession, error) GetRecentUserPromptsByProject(ctx context.Context, project string, limit int) ([]*models.UserPromptWithSession, error) FindRecentPromptByText(ctx context.Context, claudeSessionID, promptText string, withinSeconds int) (int64, int, bool) + FindRecentPromptByTextGlobal(ctx context.Context, promptText string, withinSeconds int) (int64, int, bool) } // PromptWriter defines write operations for prompts. diff --git a/internal/worker/handlers_sessions.go b/internal/worker/handlers_sessions.go index d85f374..005077a 100644 --- a/internal/worker/handlers_sessions.go +++ b/internal/worker/handlers_sessions.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -44,7 +45,30 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + // Reject requests from internal CLI calls (callClaudeCLI runs from /tmp with no session ID). + // These are the memory extraction agent's own prompts, not real user prompts. + if req.ClaudeSessionID == "" { + log.Debug().Str("project", req.Project).Msg("Rejecting session init with empty claude session ID (internal call)") + writeJSON(w, SessionInitResponse{ + Skipped: true, + Reason: "internal", + }) + return + } + // Reject prompts that contain the internal system prompt (from SDK processor). + // The SDK processor sets CLAUDE_MNEMONIC_INTERNAL=1 on the subprocess env, + // but Claude Code does NOT propagate custom env vars to hook subprocesses, + // so that guard fails and the system prompt leaks into the database as a + // regular user prompt. This server-side check catches those cases reliably. + if strings.Contains(req.Prompt, "You are a memory extraction agent for Claude Code sessions") { + log.Debug().Str("project", req.Project).Msg("Rejecting session init with internal system prompt") + writeJSON(w, SessionInitResponse{ + Skipped: true, + Reason: "internal", + }) + return + } // Privacy check if privacy.IsEntirelyPrivate(req.Prompt) { // Create session but skip processing @@ -84,6 +108,24 @@ func (s *Service) handleSessionInit(w http.ResponseWriter, r *http.Request) { return } + // CROSS-SESSION DUPLICATE DETECTION: Same prompt text from ANY session within the window. + // This catches duplicates when the SDK processor's callClaudeCLI subprocess fires + // UserPromptSubmit with a different Claude session ID than the original hook. + if _, existingNum, found := s.promptStore.FindRecentPromptByTextGlobal(r.Context(), cleanedPrompt, DuplicatePromptWindowSeconds); found { + sessionID, _ := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt) + + log.Debug(). + Int64("sessionId", sessionID). + Int("promptNumber", existingNum). + Msg("Cross-session duplicate prompt detected") + + writeJSON(w, SessionInitResponse{ + SessionDBID: sessionID, + PromptNumber: existingNum, + }) + return + } + // Create session (idempotent) sessionID, err := s.sessionStore.CreateSDKSession(r.Context(), req.ClaudeSessionID, req.Project, cleanedPrompt) if err != nil { @@ -210,6 +252,12 @@ func (s *Service) handleObservation(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + // Reject requests from internal CLI calls (no session ID = internal callClaudeCLI) + if req.ClaudeSessionID == "" { + w.WriteHeader(http.StatusOK) + return + } + // Find session sess, err := s.sessionStore.FindAnySDKSession(r.Context(), req.ClaudeSessionID) diff --git a/internal/worker/sdk/processor.go b/internal/worker/sdk/processor.go index fc5ba8c..488b4f3 100644 --- a/internal/worker/sdk/processor.go +++ b/internal/worker/sdk/processor.go @@ -696,7 +696,12 @@ func (p *Processor) callClaudeCLI(ctx context.Context, prompt string) (string, e // (hooks are triggered based on working directory) cmd.Dir = "/tmp" - // Disable any plugin hooks by setting an env var that our hooks can check + // Disable any plugin hooks by setting an env var that our hooks can check. + // NOTE: This env var is NOT reliably propagated to hook subprocesses because + // Claude Code constructs its own environment for hooks. The server-side check + // in handleSessionInit (handlers_sessions.go) provides the reliable guard by + // detecting the system prompt signature in the prompt text. This env var is + // kept as a best-effort defense-in-depth measure for cases where it does work. cmd.Env = append(os.Environ(), "CLAUDE_MNEMONIC_INTERNAL=1") // Capture output with size limits to prevent unbounded memory usage diff --git a/pkg/hooks/worker.go b/pkg/hooks/worker.go index 996c759..02fc09e 100644 --- a/pkg/hooks/worker.go +++ b/pkg/hooks/worker.go @@ -410,8 +410,6 @@ func findWorkerBinary() string { locations := []string{ "./worker", "./bin/worker", - filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/1.0.0/worker"), - filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker"), } for _, loc := range locations { @@ -420,6 +418,19 @@ func findWorkerBinary() string { } } + // Try cache directory with any version (glob returns lexically sorted matches) + matches, _ := filepath.Glob(filepath.Join(home, ".claude/plugins/cache/claude-mnemonic/claude-mnemonic/*/worker")) + if len(matches) > 0 { + // Use the last match (latest version due to lexical sorting) + return matches[len(matches)-1] + } + + // Try marketplaces directory + marketplacePath := filepath.Join(home, ".claude/plugins/marketplaces/claude-mnemonic/worker") + if _, err := os.Stat(marketplacePath); err == nil { + return marketplacePath + } + // Try PATH if path, err := exec.LookPath("claude-mnemonic-worker"); err == nil { return path diff --git a/plugin/.claude-plugin/plugin.json.tpl b/plugin/.claude-plugin/plugin.json.tpl index b6124f9..1ec79f3 100644 --- a/plugin/.claude-plugin/plugin.json.tpl +++ b/plugin/.claude-plugin/plugin.json.tpl @@ -5,5 +5,14 @@ "author": { "name": "lukaszraczylo", "email": "lukaszraczylo@users.noreply.github.com" - } + }, + "hooks": "./hooks/hooks.json", + "mcpServers": { + "claude-mnemonic": { + "command": "${CLAUDE_PLUGIN_ROOT}/mcp-server", + "args": ["--project", "${CLAUDE_PROJECT}"], + "env": {} + } + }, + "commands": ["./commands/restart.md"] } diff --git a/scripts/register-plugin.sh b/scripts/register-plugin.sh index 42fee38..de75af3 100755 --- a/scripts/register-plugin.sh +++ b/scripts/register-plugin.sh @@ -69,7 +69,101 @@ if [ ! -f "$MARKETPLACES_FILE" ]; then echo '{}' > "$MARKETPLACES_FILE" fi -# Check if jq is available +# Validate marketplace path exists and contains expected files +if [ ! -d "$MARKETPLACE_PATH" ]; then + echo "Warning: Marketplace directory not found at $MARKETPLACE_PATH" + echo "Plugin files may not be copied to cache correctly." +fi + +# Ensure cache directory exists and copy plugin files +mkdir -p "$CACHE_PATH/.claude-plugin" +mkdir -p "$CACHE_PATH/hooks" +mkdir -p "$CACHE_PATH/commands" + +# Copy files from marketplace to cache +if ! cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null; then + echo "ERROR: Failed to copy plugin files to cache directory" + exit 1 +fi + +# Verify critical files exist in cache +for f in worker mcp-server hooks/hooks.json .claude-plugin/plugin.json; do + if [ ! -f "$CACHE_PATH/$f" ]; then + echo "WARNING: Expected file $f not found in cache after copy" + fi +done + +# --- JSON registration --- +# Uses jq if available, falls back to python3 for systems without jq. + +STATUSLINE_CMD="\${CLAUDE_PLUGIN_ROOT}/hooks/statusline" + +# Helper: register plugin using python3 (jq-free fallback) +register_with_python() { + python3 - "$PLUGINS_FILE" "$SETTINGS_FILE" "$MARKETPLACES_FILE" \ + "$PLUGIN_KEY" "$CACHE_PATH" "$VERSION" "$TIMESTAMP" \ + "$STATUSLINE_CMD" "$MARKETPLACE_NAME" "$MARKETPLACE_PATH" <<'PYEOF' +import json, sys, os + +plugins_file, settings_file, marketplaces_file = sys.argv[1], sys.argv[2], sys.argv[3] +plugin_key, cache_path, version, timestamp = sys.argv[4], sys.argv[5], sys.argv[6], sys.argv[7] +statusline_cmd, marketplace_name, marketplace_path = sys.argv[8], sys.argv[9], sys.argv[10] + +def load_json(path): + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError): + return {} + +def save_json(path, data): + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(tmp, path) + +# 1. installed_plugins.json +plugins = load_json(plugins_file) +plugins.setdefault("version", 2) +plugins.setdefault("plugins", {}) +plugins["plugins"][plugin_key] = [{ + "scope": "user", + "installPath": cache_path, + "version": version, + "installedAt": timestamp, + "lastUpdated": timestamp, + "isLocal": True +}] +save_json(plugins_file, plugins) +print("Plugin registered in installed_plugins.json") + +# 2. settings.json +settings = load_json(settings_file) +settings.setdefault("enabledPlugins", {}) +settings["enabledPlugins"][plugin_key] = True +settings["statusLine"] = { + "type": "command", + "command": statusline_cmd, + "padding": 0 +} +save_json(settings_file, settings) +print("Plugin enabled in settings.json") +print("Statusline configured in settings.json") + +# 3. known_marketplaces.json +marketplaces = load_json(marketplaces_file) +marketplaces[marketplace_name] = { + "source": {"source": "directory", "path": marketplace_path}, + "installLocation": marketplace_path, + "lastUpdated": timestamp +} +save_json(marketplaces_file, marketplaces) +print("Marketplace registered in known_marketplaces.json") +PYEOF +} + +# Detect JSON tool and register if command -v jq &> /dev/null; then # Validate jq version (1.6+ required for //= operator) JQ_VERSION=$(jq --version 2>/dev/null | sed 's/jq-//') @@ -77,26 +171,18 @@ if command -v jq &> /dev/null; then JQ_MINOR=$(echo "$JQ_VERSION" | cut -d. -f2) if [ -n "$JQ_MAJOR" ] && [ -n "$JQ_MINOR" ]; then if [ "$JQ_MAJOR" -lt 1 ] || { [ "$JQ_MAJOR" -eq 1 ] && [ "$JQ_MINOR" -lt 6 ]; }; then - echo "ERROR: jq 1.6+ is required (found jq-$JQ_VERSION)" - echo "Please upgrade jq: brew install jq (macOS) or apt-get install jq (Linux)" - exit 1 + echo "jq $JQ_VERSION found but too old (need 1.6+), trying python3..." + if command -v python3 &> /dev/null; then + register_with_python + else + echo "ERROR: jq 1.6+ or python3 is required for plugin registration" + exit 1 + fi + echo "Plugin registered successfully using python3" + exit 0 fi fi - # Validate marketplace path exists and contains expected files - if [ ! -d "$MARKETPLACE_PATH" ]; then - echo "Warning: Marketplace directory not found at $MARKETPLACE_PATH" - echo "Plugin files may not be copied to cache correctly." - fi - - # Ensure cache directory exists and copy plugin files - mkdir -p "$CACHE_PATH/.claude-plugin" - mkdir -p "$CACHE_PATH/hooks" - mkdir -p "$CACHE_PATH/commands" - - # Copy files from marketplace to cache - cp -r "$MARKETPLACE_PATH/"* "$CACHE_PATH/" 2>/dev/null || true - # Use jq for proper JSON manipulation PLUGIN_ENTRY=$(cat < /dev/null; then + register_with_python + echo "Plugin registered successfully using python3" else - echo "ERROR: jq is required for plugin registration" - echo "Please install jq: brew install jq (macOS) or apt-get install jq (Linux)" + echo "ERROR: jq or python3 is required for plugin registration" + echo "Please install one: brew install jq (macOS) or apt-get install jq (Linux)" exit 1 fi diff --git a/ui/package-lock.json b/ui/package-lock.json index ffc3e03..dbd6357 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.11.43-2-g11fd196-dirty", + "version": "v0.11.47-3-gfcab3eb-dirty", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-mnemonic-dashboard", - "version": "v0.11.43-2-g11fd196-dirty", + "version": "v0.11.47-3-gfcab3eb-dirty", "dependencies": { "vis-data": "^7.1.9", "vis-network": "^9.1.9", diff --git a/ui/package.json b/ui/package.json index 67f33a6..409a3ca 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.11.43-2-g11fd196-dirty", + "version": "v0.11.47-3-gfcab3eb-dirty", "private": true, "type": "module", "scripts": {