Skip to content

Commit 40be65e

Browse files
joohwcursoragent
andcommitted
feat(desktop,core): proxy footer, session delete, and log UX improvements
Desktop adds a global proxy base URL bar, per-vendor ingress copy, model name copy with click/double-click gestures, and session deletion from the sessions detail view (0.2.9). Core filters invalid-path noise from logs, deletes sessions via debug API, fixes Claude probe fallback to stay on provider ingress paths, and bumps dev build to 0.1.75 for local proxy detection. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b4854ad commit 40be65e

27 files changed

Lines changed: 547 additions & 45 deletions

core/internal/buildinfo/buildinfo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import "strings"
44

55
// Set at link time via -ldflags (see .goreleaser.yaml).
66
var (
7-
Version = "dev0.1.73"
7+
Version = "dev0.1.75"
88
Commit = "none"
99
Date = "unknown"
1010
)

core/internal/proxy/calllog.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ func (s *CallLogStore) Clear() {
190190
_ = clearCallLogDB(s.db)
191191
}
192192

193+
// DeleteSession removes all call log rows for a grouped session key (kind-sessionId).
194+
func (s *CallLogStore) DeleteSession(sessionKey string) (int64, error) {
195+
if s == nil || s.db == nil {
196+
return 0, errors.New("call log store unavailable")
197+
}
198+
kind, sessionID, ok := parseCallLogSessionKey(sessionKey)
199+
if !ok {
200+
return 0, errors.New("invalid session key")
201+
}
202+
s.mu.Lock()
203+
defer s.mu.Unlock()
204+
return deleteCallLogsBySession(s.db, kind, sessionID)
205+
}
206+
193207
func shouldRecordCallLog(path string) bool {
194208
p := strings.TrimSpace(path)
195209
if p == "" {
@@ -214,9 +228,9 @@ func isProbeMethod(method string) bool {
214228
}
215229

216230
// shouldUseCallLog decides whether a request gets a full call-log trace.
217-
// GET/HEAD probes (connectivity checks, invalid paths) go to system logs instead.
231+
// Invalid ingress paths and GET/HEAD probes (except GET /v1/models) are excluded.
218232
func shouldUseCallLog(r *http.Request, ingress provider.Ingress, pathOK bool) bool {
219-
if r == nil {
233+
if r == nil || !pathOK {
220234
return false
221235
}
222236
path := r.URL.EscapedPath()
@@ -229,7 +243,7 @@ func shouldUseCallLog(r *http.Request, ingress provider.Ingress, pathOK bool) bo
229243
if !isProbeMethod(r.Method) {
230244
return true
231245
}
232-
return pathOK && isModelsPath(ingress.PathSuffix)
246+
return isModelsPath(ingress.PathSuffix)
233247
}
234248

235249
func startRequestTraceIfNeeded(store *CallLogStore, r *http.Request, ingress provider.Ingress, pathOK bool) *requestTrace {
@@ -239,11 +253,24 @@ func startRequestTraceIfNeeded(store *CallLogStore, r *http.Request, ingress pro
239253
return startRequestTrace(store, r)
240254
}
241255

242-
func logProxyProbeIfNeeded(r *http.Request, trace *requestTrace, status int, detail string) {
243-
if trace != nil || r == nil || !isProbeMethod(r.Method) {
256+
func logProxyProbeIfNeeded(r *http.Request, trace *requestTrace, pathOK bool, status int, detail string) {
257+
if trace != nil || r == nil || pathOK {
258+
return
259+
}
260+
if isHostLevelLegacyV1Path(inboundRequestPath(r)) {
261+
return
262+
}
263+
if !isProbeMethod(r.Method) {
244264
return
245265
}
246-
syslog.LogProxyProbe(r.Method, inboundRequestURL(r), status, detail)
266+
syslog.LogProxyProbe(r.Method, inboundRequestPath(r), status, detail)
267+
}
268+
269+
// isHostLevelLegacyV1Path matches bare /v1/... requests (no /{providerId} prefix).
270+
// Connectivity fallbacks used to hit these against the local proxy; they are not logged.
271+
func isHostLevelLegacyV1Path(path string) bool {
272+
parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/")
273+
return len(parts) >= 2 && strings.EqualFold(parts[0], "v1")
247274
}
248275

249276
type requestTrace struct {
@@ -278,7 +305,7 @@ func startRequestTrace(store *CallLogStore, r *http.Request) *requestTrace {
278305
}
279306
}
280307

281-
func inboundRequestURL(r *http.Request) string {
308+
func inboundRequestPath(r *http.Request) string {
282309
if r == nil || r.URL == nil {
283310
return ""
284311
}
@@ -287,8 +314,16 @@ func inboundRequestURL(r *http.Request) string {
287314
path = r.URL.Path
288315
}
289316
if r.URL.RawQuery != "" {
290-
path = path + "?" + r.URL.RawQuery
317+
return path + "?" + r.URL.RawQuery
318+
}
319+
return path
320+
}
321+
322+
func inboundRequestURL(r *http.Request) string {
323+
if r == nil || r.URL == nil {
324+
return ""
291325
}
326+
path := inboundRequestPath(r)
292327
host := strings.TrimSpace(r.Host)
293328
if host == "" {
294329
host = strings.TrimSpace(r.URL.Host)

core/internal/proxy/calllog_session.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,29 @@ func callLogSessionKey(kind, sessionID string) string {
116116
return k + "-" + id
117117
}
118118

119+
var callLogSessionKindPrefixes = []string{"claudecode", "codex", "opencode", "test"}
120+
121+
// parseCallLogSessionKey splits a UI session key (kind-sessionId) back into DB fields.
122+
func parseCallLogSessionKey(session string) (kind, sessionID string, ok bool) {
123+
session = strings.TrimSpace(session)
124+
if session == "" {
125+
return "", "", false
126+
}
127+
lower := strings.ToLower(session)
128+
for _, prefix := range callLogSessionKindPrefixes {
129+
marker := prefix + "-"
130+
if !strings.HasPrefix(lower, marker) {
131+
continue
132+
}
133+
id := strings.TrimSpace(session[len(marker):])
134+
if id == "" {
135+
return "", "", false
136+
}
137+
return prefix, id, true
138+
}
139+
return "", "", false
140+
}
141+
119142
func sanitizeSessionFilename(sessionID string) string {
120143
id := strings.TrimSpace(sessionID)
121144
if id == "" {

core/internal/proxy/calllog_session_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,42 @@ func TestListCallLogSessions(t *testing.T) {
178178
}
179179
}
180180

181+
func TestParseCallLogSessionKey(t *testing.T) {
182+
kind, id, ok := parseCallLogSessionKey("codex-019e64f8-2705-7e43-8413-d2d823f16f50")
183+
if !ok || kind != "codex" || id != "019e64f8-2705-7e43-8413-d2d823f16f50" {
184+
t.Fatalf("parse codex session = %q %q ok=%v", kind, id, ok)
185+
}
186+
if _, _, ok := parseCallLogSessionKey("invalid"); ok {
187+
t.Fatal("expected invalid session key to fail")
188+
}
189+
}
190+
191+
func TestDeleteCallLogSession(t *testing.T) {
192+
store := openTestCallLogStore(t)
193+
store.Push(CallLogEntry{
194+
Request: CallLogRequest{
195+
Method: "POST",
196+
URL: "/codex/gpt-5.4/openai-responses/v1/responses",
197+
Headers: map[string]string{
198+
"Originator": "codex-tui",
199+
"Session-Id": "sess-delete-me",
200+
},
201+
},
202+
})
203+
store.Push(CallLogEntry{Request: CallLogRequest{Method: "POST", URL: "/other"}})
204+
205+
deleted, err := store.DeleteSession("codex-sess-delete-me")
206+
if err != nil || deleted != 1 {
207+
t.Fatalf("DeleteSession() = %d, %v", deleted, err)
208+
}
209+
if entries := store.ListRecent(0); len(entries) != 1 {
210+
t.Fatalf("expected 1 remaining entry, got %d", len(entries))
211+
}
212+
if sessions := store.ListSessions(0); len(sessions) != 0 {
213+
t.Fatalf("expected no session groups, got %#v", sessions)
214+
}
215+
}
216+
181217
func TestClearCallLogDB(t *testing.T) {
182218
store := openTestCallLogStore(t)
183219
store.Push(CallLogEntry{Request: CallLogRequest{Method: "POST", URL: "/a"}})

core/internal/proxy/calllog_sqlite.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,26 @@ func clearCallLogDB(db *sql.DB) error {
311311
return nil
312312
}
313313

314+
func deleteCallLogsBySession(db *sql.DB, kind, sessionID string) (int64, error) {
315+
if db == nil {
316+
return 0, errors.New("call log db is nil")
317+
}
318+
kind = strings.TrimSpace(strings.ToLower(kind))
319+
sessionID = strings.TrimSpace(sessionID)
320+
if kind == "" || sessionID == "" {
321+
return 0, errors.New("session kind and id are required")
322+
}
323+
result, err := db.Exec(
324+
`DELETE FROM call_logs WHERE session_kind = ? AND session_id = ?`,
325+
kind,
326+
sessionID,
327+
)
328+
if err != nil {
329+
return 0, err
330+
}
331+
return result.RowsAffected()
332+
}
333+
314334
func exportCallLogDB(db *sql.DB, w io.Writer) (int, error) {
315335
if db == nil {
316336
return 0, errors.New("call log db is nil")

core/internal/proxy/calllog_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,27 @@ func TestShouldUseCallLogRoutesProbesToSystemLog(t *testing.T) {
9494
if !shouldUseCallLog(post, ingress, ok) {
9595
t.Fatal("POST messages should stay in call log")
9696
}
97+
98+
invalidPost, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:27483/v1/messages", nil)
99+
invalidIngress, invalidOK := provider.ParseProxyIngressPath(invalidPost.URL.Path)
100+
if invalidOK {
101+
t.Fatal("host-level /v1/messages must not parse as ingress")
102+
}
103+
if shouldUseCallLog(invalidPost, invalidIngress, invalidOK) {
104+
t.Fatal("POST on invalid host-level /v1 path should skip call log")
105+
}
106+
}
107+
108+
func TestIsHostLevelLegacyV1Path(t *testing.T) {
109+
if !isHostLevelLegacyV1Path("/v1/messages") {
110+
t.Fatal("expected host-level /v1/messages")
111+
}
112+
if !isHostLevelLegacyV1Path("/v1/chat/completions") {
113+
t.Fatal("expected host-level /v1/chat/completions")
114+
}
115+
if isHostLevelLegacyV1Path("/claude-code/v1/messages") {
116+
t.Fatal("provider ingress path must not match host-level /v1")
117+
}
97118
}
98119

99120
func TestCallLogPreservesFullBody(t *testing.T) {

core/internal/proxy/server.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,24 @@ func (s *Server) handleDebugCallLog(w http.ResponseWriter, r *http.Request) {
183183
"sessions": s.CallLogs.ListSessions(100),
184184
})
185185
case http.MethodDelete:
186+
sessionKey := strings.TrimSpace(r.URL.Query().Get("session"))
187+
if sessionKey != "" {
188+
deleted, err := s.CallLogs.DeleteSession(sessionKey)
189+
if err != nil {
190+
status := http.StatusInternalServerError
191+
if strings.Contains(strings.ToLower(err.Error()), "invalid session") {
192+
status = http.StatusBadRequest
193+
}
194+
writeJSON(w, status, map[string]string{"error": err.Error()})
195+
return
196+
}
197+
writeJSON(w, http.StatusOK, map[string]any{
198+
"ok": "deleted",
199+
"deleted": deleted,
200+
"session": sessionKey,
201+
})
202+
return
203+
}
186204
s.CallLogs.Clear()
187205
writeJSON(w, http.StatusOK, map[string]string{"ok": "cleared"})
188206
default:
@@ -465,7 +483,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
465483
trace.setRequestBody(payload)
466484
if bodyErr != nil {
467485
trace.setError("read request body")
468-
logProxyProbeIfNeeded(r, trace, http.StatusBadRequest, "read request body")
486+
logProxyProbeIfNeeded(r, trace, pathOK, http.StatusBadRequest, "read request body")
469487
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read request body"})
470488
return
471489
}
@@ -474,7 +492,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
474492
if !pathOK {
475493
msg := "invalid path; use /{providerId}/v1/messages, /{providerId}/v1/responses, or /{providerId}/v1/chat/completions"
476494
trace.setError(msg)
477-
logProxyProbeIfNeeded(r, trace, http.StatusNotFound, msg)
495+
logProxyProbeIfNeeded(r, trace, pathOK, http.StatusNotFound, msg)
478496
writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
479497
return
480498
}
@@ -494,7 +512,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
494512
if !shouldTransformProxyMethod(r.Method) {
495513
msg := "method not supported for proxy route"
496514
trace.setError(msg)
497-
logProxyProbeIfNeeded(r, trace, http.StatusMethodNotAllowed, msg)
515+
logProxyProbeIfNeeded(r, trace, pathOK, http.StatusMethodNotAllowed, msg)
498516
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": msg})
499517
return
500518
}

core/internal/testclient/testclient.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10-
"net/url"
1110
"strings"
1211
"time"
1312

@@ -86,7 +85,7 @@ func ProbeToolRoundTrip(openAIResponsesBaseURL, claudeMessagesBaseURL, apiKey, m
8685
return nil
8786
}
8887

89-
// probeClaudeWithFallback tries Anthropic Messages on base, then OpenAI chat on the same origin when Messages is 404.
88+
// probeClaudeWithFallback tries Anthropic Messages on base, then OpenAI chat on the same base when Messages is 404.
9089
func probeClaudeWithFallback(base, apiKey, model string) error {
9190
mErr := probeAnthropicMessagesPOST(base, apiKey, model)
9291
if mErr == nil {
@@ -95,12 +94,7 @@ func probeClaudeWithFallback(base, apiKey, model string) error {
9594
if !errors.Is(mErr, errAnthropicMessagesNotFound) {
9695
return mErr
9796
}
98-
u, perr := url.Parse(base)
99-
if perr != nil || u.Host == "" {
100-
return mErr
101-
}
102-
origin := u.Scheme + "://" + u.Host
103-
return probeOpenAIChatPOST(origin, apiKey, model)
97+
return probeOpenAIChatPOST(base, apiKey, model)
10498
}
10599

106100
func probeAnthropicMessagesPOST(base, apiKey, model string) error {

core/internal/testclient/testclient_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ func TestProbeClaudeFallbackWhenMessages404(t *testing.T) {
201201
return
202202
}
203203
http.NotFound(w, r)
204-
case "/v1/chat/completions":
204+
case "/anthropic/v1/chat/completions":
205205
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer sk-test" {
206206
w.WriteHeader(http.StatusUnauthorized)
207207
return

electron/call-logs-store.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,18 @@ async function clearProxyDebugLogs(scope = "all", options = {}) {
156156
}
157157
}
158158

159+
async function deleteCallLogSessionViaHTTP(session, options = {}) {
160+
const key = String(session || "").trim();
161+
if (!key) {
162+
throw new Error("session is required");
163+
}
164+
return fetchProxyDebugJSON(
165+
"/__debug/call-log",
166+
{ session: key },
167+
{ method: "DELETE", proxy: options.proxy },
168+
);
169+
}
170+
159171
module.exports = {
160172
logsDir,
161173
callLogsDBPath,
@@ -169,4 +181,5 @@ module.exports = {
169181
readSystemLogsViaHTTP,
170182
clearSystemLogsViaCLI,
171183
clearProxyDebugLogs,
184+
deleteCallLogSessionViaHTTP,
172185
};

0 commit comments

Comments
 (0)