Skip to content

Commit 108aee3

Browse files
committed
fix(spec-044): restore destructive-mode 409 guard (gemini P2, was dead code)
The 409 guard at diagnostics_fix.go was unreachable: mode was defaulted to dry_run before the check, so `mode == ModeExecute && body.Mode != ModeExecute` could never fire. Destructive fixers silently downgraded to dry_run when the client forgot the mode field, masking implementation errors. Move the check ahead of the default: - destructive + body.Mode == "" -> 409 (force explicit intent) - non-destructive + body.Mode == "" -> defaults to execute - explicit dry_run/execute -> proceed as before Adds diagnostics_fix_test.go with 4 subtests covering each path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 912946f commit 108aee3

2 files changed

Lines changed: 118 additions & 13 deletions

File tree

internal/httpapi/diagnostics_fix.go

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,27 +92,26 @@ func (s *Server) handleInvokeFix(w http.ResponseWriter, r *http.Request) {
9292
return
9393
}
9494

95-
// Determine mode. Destructive defaults to dry_run.
95+
// Destructive fixes require an explicit mode (dry_run or execute). An
96+
// absent mode field on a destructive fixer returns 409 so the client is
97+
// forced to declare intent. Previously the default-to-dry_run path masked
98+
// this guard and made the check unreachable — gemini P2.
99+
if step.Destructive && body.Mode == "" {
100+
s.writeError(w, r, http.StatusConflict, "destructive fix requires explicit mode (dry_run or execute)")
101+
return
102+
}
103+
104+
// Determine mode. Non-destructive fixes default to execute. (Destructive
105+
// fixes never reach the default branch because of the 409 guard above.)
96106
mode := body.Mode
97107
if mode == "" {
98-
if step.Destructive {
99-
mode = diagnostics.ModeDryRun
100-
} else {
101-
mode = diagnostics.ModeExecute
102-
}
108+
mode = diagnostics.ModeExecute
103109
}
104110
if mode != diagnostics.ModeDryRun && mode != diagnostics.ModeExecute {
105111
s.writeError(w, r, http.StatusBadRequest, "mode must be 'dry_run' or 'execute'")
106112
return
107113
}
108114

109-
// Block destructive execute without an explicit mode change. The client
110-
// must send mode=execute intentionally.
111-
if step.Destructive && mode == diagnostics.ModeExecute && body.Mode != diagnostics.ModeExecute {
112-
s.writeError(w, r, http.StatusConflict, "destructive fix requires explicit mode=execute")
113-
return
114-
}
115-
116115
// Rate limit per (server, code).
117116
key := body.Server + "|" + body.Code
118117
if ok, retry := globalFixLimiter.allow(key); !ok {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package httpapi
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
"time"
11+
12+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/diagnostics"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
"go.uber.org/zap"
17+
)
18+
19+
// mockFixController gives us GetCurrentConfig; the rest comes from baseController.
20+
type mockFixController struct {
21+
baseController
22+
apiKey string
23+
}
24+
25+
func (m *mockFixController) GetCurrentConfig() any {
26+
return &config.Config{APIKey: m.apiKey}
27+
}
28+
29+
// resetFixLimiter clears the global rate limiter between subtests so 1/s rate
30+
// limiting doesn't cause spurious 429s.
31+
func resetFixLimiter() {
32+
globalFixLimiter.mu.Lock()
33+
globalFixLimiter.last = map[string]time.Time{}
34+
globalFixLimiter.mu.Unlock()
35+
}
36+
37+
// TestDiagnosticsFix_ModeGuard covers the destructive-mode 409 guard restored
38+
// in the gemini P2 fix. Previously mode="" + destructive was silently downgraded
39+
// to dry_run, making the guard dead code.
40+
func TestDiagnosticsFix_ModeGuard(t *testing.T) {
41+
logger := zap.NewNop().Sugar()
42+
mockCtrl := &mockFixController{apiKey: "test-key"}
43+
srv := NewServer(mockCtrl, logger, nil)
44+
45+
// Register a harmless fixer for both the non-destructive and destructive
46+
// fix-step keys we reference below. InvokeFixer will succeed without side
47+
// effects.
48+
diagnostics.Register("stdio_show_last_logs", func(ctx context.Context, _ diagnostics.FixRequest) (diagnostics.FixResult, error) {
49+
return diagnostics.FixResult{Outcome: diagnostics.OutcomeSuccess, Preview: "ok"}, nil
50+
})
51+
diagnostics.Register("oauth_reauth", func(ctx context.Context, _ diagnostics.FixRequest) (diagnostics.FixResult, error) {
52+
return diagnostics.FixResult{Outcome: diagnostics.OutcomeSuccess, Preview: "ok"}, nil
53+
})
54+
55+
call := func(t *testing.T, body fixRequestBody) *httptest.ResponseRecorder {
56+
t.Helper()
57+
resetFixLimiter()
58+
raw, err := json.Marshal(body)
59+
require.NoError(t, err)
60+
req := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/fix", bytes.NewReader(raw))
61+
req.Header.Set("Content-Type", "application/json")
62+
req.Header.Set("X-API-Key", "test-key")
63+
w := httptest.NewRecorder()
64+
srv.ServeHTTP(w, req)
65+
return w
66+
}
67+
68+
t.Run("non-destructive fixer, mode unset -> 200 (defaults to execute)", func(t *testing.T) {
69+
w := call(t, fixRequestBody{
70+
Server: "srv1",
71+
Code: string(diagnostics.STDIOSpawnENOENT),
72+
FixerKey: "stdio_show_last_logs",
73+
})
74+
assert.Equal(t, http.StatusOK, w.Code, "non-destructive fix without mode should succeed")
75+
})
76+
77+
t.Run("destructive fixer, mode unset -> 409", func(t *testing.T) {
78+
w := call(t, fixRequestBody{
79+
Server: "srv1",
80+
Code: string(diagnostics.OAuthRefreshExpired),
81+
FixerKey: "oauth_reauth",
82+
})
83+
assert.Equal(t, http.StatusConflict, w.Code,
84+
"destructive fix with absent mode must return 409 (gemini P2 guard)")
85+
})
86+
87+
t.Run("destructive fixer, mode=dry_run -> 200", func(t *testing.T) {
88+
w := call(t, fixRequestBody{
89+
Server: "srv1",
90+
Code: string(diagnostics.OAuthRefreshExpired),
91+
FixerKey: "oauth_reauth",
92+
Mode: diagnostics.ModeDryRun,
93+
})
94+
assert.Equal(t, http.StatusOK, w.Code, "destructive fix with explicit dry_run should succeed")
95+
})
96+
97+
t.Run("destructive fixer, mode=execute -> 200", func(t *testing.T) {
98+
w := call(t, fixRequestBody{
99+
Server: "srv1",
100+
Code: string(diagnostics.OAuthRefreshExpired),
101+
FixerKey: "oauth_reauth",
102+
Mode: diagnostics.ModeExecute,
103+
})
104+
assert.Equal(t, http.StatusOK, w.Code, "destructive fix with explicit execute should succeed")
105+
})
106+
}

0 commit comments

Comments
 (0)