webctx 0.2.8 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -1
- package/CONTRIBUTORS.md +2 -2
- package/README.md +27 -2
- package/internal/app/app.go +73 -2
- package/internal/app/app_test.go +7 -0
- package/internal/app/providers.go +165 -0
- package/internal/app/providers_test.go +305 -0
- package/internal/app/scrape.go +1 -1
- package/internal/app/search_keys.go +233 -0
- package/internal/app/search_keys_test.go +227 -0
- package/internal/app/tools.go +116 -62
- package/package.json +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"compress/gzip"
|
|
6
|
+
"context"
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"io"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/http/httptest"
|
|
11
|
+
"os"
|
|
12
|
+
"path/filepath"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"testing"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func TestProviderCommandsPersistAndReport(t *testing.T) {
|
|
19
|
+
path := filepath.Join(t.TempDir(), "webctx", "config.json")
|
|
20
|
+
t.Setenv("WEBCTX_CONFIG_FILE", path)
|
|
21
|
+
t.Setenv("BRAVE_API_KEY", "test-brave-secret")
|
|
22
|
+
t.Setenv("TAVILY_API_KEY", "")
|
|
23
|
+
t.Setenv("EXA_API_KEY", "test-exa-secret")
|
|
24
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
25
|
+
|
|
26
|
+
run := func(args ...string) (int, string, string) {
|
|
27
|
+
t.Helper()
|
|
28
|
+
var out, errOut bytes.Buffer
|
|
29
|
+
code := Run(args, &out, &errOut)
|
|
30
|
+
return code, out.String(), errOut.String()
|
|
31
|
+
}
|
|
32
|
+
if code, text, errText := run("status"); code != 0 || !strings.Contains(text, "exa enabled") || !strings.Contains(text, "brave enabled") || errText != "" {
|
|
33
|
+
t.Fatalf("default status: code=%d text=%q error=%q", code, text, errText)
|
|
34
|
+
}
|
|
35
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
36
|
+
t.Fatalf("status should not create config file, got error %v", err)
|
|
37
|
+
}
|
|
38
|
+
if code, text, errText := run("providers", "disable", "ExA"); code != 0 || !strings.Contains(text, "exa disabled") || errText != "" {
|
|
39
|
+
t.Fatalf("disable: code=%d text=%q error=%q", code, text, errText)
|
|
40
|
+
}
|
|
41
|
+
if code, text, _ := run("providers"); code != 0 || !strings.Contains(text, "exa disabled") || !strings.Contains(text, "key configured") {
|
|
42
|
+
t.Fatalf("persisted status: code=%d text=%q", code, text)
|
|
43
|
+
} else if strings.Contains(text, "test-exa-secret") || strings.Contains(text, "test-brave-secret") {
|
|
44
|
+
t.Fatalf("status exposed a credential: %q", text)
|
|
45
|
+
}
|
|
46
|
+
content, err := os.ReadFile(path)
|
|
47
|
+
if err != nil {
|
|
48
|
+
t.Fatal(err)
|
|
49
|
+
}
|
|
50
|
+
var config providerConfig
|
|
51
|
+
if err := json.Unmarshal(content, &config); err != nil || !strings.EqualFold(strings.Join(config.DisabledProviders, ","), "exa") {
|
|
52
|
+
t.Fatalf("settings not persisted: %s; error=%v", content, err)
|
|
53
|
+
}
|
|
54
|
+
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 {
|
|
55
|
+
t.Fatalf("config permissions: info=%v error=%v", info, err)
|
|
56
|
+
}
|
|
57
|
+
if code, text, errText := run("providers", "enable", "exa"); code != 0 || !strings.Contains(text, "exa enabled") || errText != "" {
|
|
58
|
+
t.Fatalf("enable: code=%d text=%q error=%q", code, text, errText)
|
|
59
|
+
}
|
|
60
|
+
if code, text, _ := run("status"); code != 0 || !strings.Contains(text, "exa enabled") {
|
|
61
|
+
t.Fatalf("restored status: code=%d text=%q", code, text)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func TestProviderCommandsRejectInvalidInputAndMalformedConfig(t *testing.T) {
|
|
66
|
+
path := filepath.Join(t.TempDir(), "config.json")
|
|
67
|
+
t.Setenv("WEBCTX_CONFIG_FILE", path)
|
|
68
|
+
for _, args := range [][]string{{"providers", "disable", "firecrawl"}, {"providers", "disable", "unknown"}, {"providers", "disable"}, {"status", "enable", "exa"}} {
|
|
69
|
+
var out, errOut bytes.Buffer
|
|
70
|
+
if Run(args, &out, &errOut) == 0 || errOut.Len() == 0 {
|
|
71
|
+
t.Fatalf("invalid command %q succeeded: %q", args, out.String())
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
75
|
+
t.Fatalf("invalid command wrote config: %v", err)
|
|
76
|
+
}
|
|
77
|
+
if err := os.WriteFile(path, []byte("{invalid"), 0o600); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
var out, errOut bytes.Buffer
|
|
81
|
+
if Run([]string{"status"}, &out, &errOut) == 0 || !strings.Contains(errOut.String(), "invalid provider settings") {
|
|
82
|
+
t.Fatalf("malformed config not reported: %q", errOut.String())
|
|
83
|
+
}
|
|
84
|
+
if Run([]string{"providers", "disable", "exa"}, &out, &errOut) == 0 {
|
|
85
|
+
t.Fatal("malformed config should not be overwritten")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type providerRequests struct {
|
|
90
|
+
mu sync.Mutex
|
|
91
|
+
queries map[string][]string
|
|
92
|
+
failedExa bool
|
|
93
|
+
emptyExa bool
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func mockSearchProviders(t *testing.T, exaFailure bool) *providerRequests {
|
|
97
|
+
t.Helper()
|
|
98
|
+
prev := http.DefaultClient.Transport
|
|
99
|
+
stats := &providerRequests{queries: make(map[string][]string), failedExa: exaFailure}
|
|
100
|
+
http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
101
|
+
provider, query := "", ""
|
|
102
|
+
switch req.URL.Host {
|
|
103
|
+
case "api.search.brave.com":
|
|
104
|
+
provider, query = "brave", req.URL.Query().Get("q")
|
|
105
|
+
case "api.tavily.com", "api.exa.ai":
|
|
106
|
+
if req.URL.Host == "api.tavily.com" {
|
|
107
|
+
provider = "tavily"
|
|
108
|
+
} else {
|
|
109
|
+
provider = "exa"
|
|
110
|
+
}
|
|
111
|
+
data, _ := io.ReadAll(req.Body)
|
|
112
|
+
var payload map[string]any
|
|
113
|
+
_ = json.Unmarshal(data, &payload)
|
|
114
|
+
query, _ = payload["query"].(string)
|
|
115
|
+
case "provider-test.example":
|
|
116
|
+
return testHTTPResponse(req, http.StatusOK, "# Example documentation\n\nThis markdown page contains enough content for a test.", map[string]string{"Content-Type": "text/markdown"}), nil
|
|
117
|
+
default:
|
|
118
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
119
|
+
}
|
|
120
|
+
stats.mu.Lock()
|
|
121
|
+
stats.queries[provider] = append(stats.queries[provider], query)
|
|
122
|
+
stats.mu.Unlock()
|
|
123
|
+
switch provider {
|
|
124
|
+
case "brave":
|
|
125
|
+
return testHTTPResponse(req, http.StatusOK, `{"web":{"results":[{"url":"https://provider-test.example/guide.md","title":"Guide","description":"Documentation"}]}}`, nil), nil
|
|
126
|
+
case "tavily":
|
|
127
|
+
return testHTTPResponse(req, http.StatusOK, `{"results":[]}`, nil), nil
|
|
128
|
+
case "exa":
|
|
129
|
+
if stats.failedExa {
|
|
130
|
+
return testHTTPResponse(req, http.StatusPaymentRequired, `{"error":"NO_MORE_CREDITS"}`, nil), nil
|
|
131
|
+
}
|
|
132
|
+
if stats.emptyExa {
|
|
133
|
+
return testHTTPResponse(req, http.StatusOK, `{"results":[]}`, nil), nil
|
|
134
|
+
}
|
|
135
|
+
return testHTTPResponse(req, http.StatusOK, `{"results":[{"url":"https://provider-test.example/guide.md","title":"Guide","text":"Documentation"}]}`, nil), nil
|
|
136
|
+
}
|
|
137
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
138
|
+
})
|
|
139
|
+
t.Cleanup(func() { http.DefaultClient.Transport = prev })
|
|
140
|
+
return stats
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func (p *providerRequests) received(provider string) []string {
|
|
144
|
+
p.mu.Lock()
|
|
145
|
+
defer p.mu.Unlock()
|
|
146
|
+
return append([]string(nil), p.queries[provider]...)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func searchTestEnv(t *testing.T) {
|
|
150
|
+
t.Helper()
|
|
151
|
+
t.Setenv("WEBCTX_CONFIG_FILE", filepath.Join(t.TempDir(), "config.json"))
|
|
152
|
+
t.Setenv("BRAVE_API_KEY", "test-brave")
|
|
153
|
+
t.Setenv("TAVILY_API_KEY", "test-tavily")
|
|
154
|
+
t.Setenv("EXA_API_KEY", "test-exa")
|
|
155
|
+
t.Setenv("BRAVE_API_KEYS", "")
|
|
156
|
+
t.Setenv("TAVILY_API_KEYS", "")
|
|
157
|
+
t.Setenv("EXA_API_KEYS", "")
|
|
158
|
+
t.Setenv("FIRECRAWL_API_KEY", "")
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func TestSearchDoesNotCallDisabledExa(t *testing.T) {
|
|
162
|
+
searchTestEnv(t)
|
|
163
|
+
stats := mockSearchProviders(t, false)
|
|
164
|
+
if _, err := setSearchProviderEnabled("exa", false); err != nil {
|
|
165
|
+
t.Fatal(err)
|
|
166
|
+
}
|
|
167
|
+
result, err := Search(SearchParams{Query: "runtime docs"})
|
|
168
|
+
if err != nil || !strings.Contains(result, "Total Results: 1") {
|
|
169
|
+
t.Fatalf("search without Exa failed: %v; %q", err, result)
|
|
170
|
+
}
|
|
171
|
+
if got := stats.received("exa"); len(got) != 0 {
|
|
172
|
+
t.Fatalf("Exa was contacted despite being disabled: %v", got)
|
|
173
|
+
}
|
|
174
|
+
if got := stats.received("brave"); len(got) != 1 || got[0] != "runtime docs" {
|
|
175
|
+
t.Fatalf("Brave was not used: %v", got)
|
|
176
|
+
}
|
|
177
|
+
if got := stats.received("tavily"); len(got) != 1 {
|
|
178
|
+
t.Fatalf("Tavily was not used: %v", got)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
func TestSearchSupportsOnlyBraveEnabled(t *testing.T) {
|
|
183
|
+
searchTestEnv(t)
|
|
184
|
+
stats := mockSearchProviders(t, false)
|
|
185
|
+
for _, name := range []string{"exa", "tavily"} {
|
|
186
|
+
if _, err := setSearchProviderEnabled(name, false); err != nil {
|
|
187
|
+
t.Fatal(err)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
result, err := Search(SearchParams{Query: "runtime docs"})
|
|
191
|
+
if err != nil || !strings.Contains(result, "Total Results: 1") {
|
|
192
|
+
t.Fatalf("single-provider search failed: %v; %q", err, result)
|
|
193
|
+
}
|
|
194
|
+
if len(stats.received("exa")) != 0 || len(stats.received("tavily")) != 0 || len(stats.received("brave")) != 1 {
|
|
195
|
+
t.Fatalf("unexpected requests: %#v", stats.queries)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
func TestKeywordFallbackSkipsDisabledExa(t *testing.T) {
|
|
200
|
+
searchTestEnv(t)
|
|
201
|
+
stats := mockSearchProviders(t, false)
|
|
202
|
+
if _, err := setSearchProviderEnabled("exa", false); err != nil {
|
|
203
|
+
t.Fatal(err)
|
|
204
|
+
}
|
|
205
|
+
result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
|
|
206
|
+
if err != nil || !strings.Contains(result, "Keyword fallback") {
|
|
207
|
+
t.Fatalf("keyword fallback failed: %v; %q", err, result)
|
|
208
|
+
}
|
|
209
|
+
if len(stats.received("exa")) != 0 {
|
|
210
|
+
t.Fatal("disabled Exa received keyword request")
|
|
211
|
+
}
|
|
212
|
+
for _, provider := range []string{"brave", "tavily"} {
|
|
213
|
+
got := stats.received(provider)
|
|
214
|
+
if len(got) != 1 || !strings.Contains(got[0], `"migration guide"`) {
|
|
215
|
+
t.Fatalf("%s did not receive a quoted phrase: %v", provider, got)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func TestKeywordFallbackOnExaCreditExhaustion(t *testing.T) {
|
|
221
|
+
searchTestEnv(t)
|
|
222
|
+
stats := mockSearchProviders(t, true)
|
|
223
|
+
result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
|
|
224
|
+
if err != nil || !strings.Contains(result, "Keyword fallback") || !strings.Contains(result, "Total Results: 1") {
|
|
225
|
+
t.Fatalf("keyword fallback after Exa error failed: %v; %q", err, result)
|
|
226
|
+
}
|
|
227
|
+
if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 1 {
|
|
228
|
+
t.Fatalf("unexpected provider request sequence: %#v", stats.queries)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func TestKeywordExaSuccessDoesNotContactOtherProviders(t *testing.T) {
|
|
233
|
+
searchTestEnv(t)
|
|
234
|
+
stats := mockSearchProviders(t, false)
|
|
235
|
+
result, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
|
|
236
|
+
if err != nil || strings.Contains(result, "Keyword fallback") || !strings.Contains(result, "Total Results: 1") {
|
|
237
|
+
t.Fatalf("unexpected keyword results: %v; %q", err, result)
|
|
238
|
+
}
|
|
239
|
+
if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 0 || len(stats.received("tavily")) != 0 {
|
|
240
|
+
t.Fatalf("unexpected provider requests: %#v", stats.queries)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func TestKeywordExaNoMatchesDoesNotBroadenSearch(t *testing.T) {
|
|
245
|
+
searchTestEnv(t)
|
|
246
|
+
stats := mockSearchProviders(t, false)
|
|
247
|
+
stats.emptyExa = true
|
|
248
|
+
_, err := Search(SearchParams{Query: "drizzle orm", IncludeKeyword: "migration guide"})
|
|
249
|
+
if err == nil || !strings.Contains(err.Error(), "failed to return results") {
|
|
250
|
+
t.Fatalf("unexpected result for zero Exa matches: %v", err)
|
|
251
|
+
}
|
|
252
|
+
if len(stats.received("exa")) != 1 || len(stats.received("brave")) != 0 || len(stats.received("tavily")) != 0 {
|
|
253
|
+
t.Fatalf("strict keyword search was broadened: %#v", stats.queries)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
func TestSearchWithAllProvidersDisabledFailsFast(t *testing.T) {
|
|
258
|
+
searchTestEnv(t)
|
|
259
|
+
stats := mockSearchProviders(t, false)
|
|
260
|
+
for _, name := range []string{"brave", "tavily", "exa"} {
|
|
261
|
+
if _, err := setSearchProviderEnabled(name, false); err != nil {
|
|
262
|
+
t.Fatal(err)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
_, err := Search(SearchParams{Query: "anything"})
|
|
266
|
+
if err == nil || !strings.Contains(err.Error(), "all search providers are disabled") {
|
|
267
|
+
t.Fatalf("unexpected error when all disabled: %v", err)
|
|
268
|
+
}
|
|
269
|
+
if len(stats.received("exa"))+len(stats.received("tavily"))+len(stats.received("brave")) != 0 {
|
|
270
|
+
t.Fatal("search contacted provider with all disabled")
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
func TestBraveSearchTransparentlyDecodesGzip(t *testing.T) {
|
|
275
|
+
t.Setenv("BRAVE_API_KEY", "test-brave")
|
|
276
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
277
|
+
if r.Header.Get("Accept-Encoding") != "gzip" {
|
|
278
|
+
t.Errorf("expected Go HTTP transport to request gzip, got %q", r.Header.Get("Accept-Encoding"))
|
|
279
|
+
}
|
|
280
|
+
w.Header().Set("Content-Encoding", "gzip")
|
|
281
|
+
w.Header().Set("Content-Type", "application/json")
|
|
282
|
+
writer := gzip.NewWriter(w)
|
|
283
|
+
_, _ = writer.Write([]byte(`{"web":{"results":[{"url":"https://example.com/guide","title":"Guide","description":"Documentation"}]}}`))
|
|
284
|
+
_ = writer.Close()
|
|
285
|
+
}))
|
|
286
|
+
defer server.Close()
|
|
287
|
+
|
|
288
|
+
originalTransport := http.DefaultClient.Transport
|
|
289
|
+
http.DefaultClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
290
|
+
if req.Header.Get("Accept-Encoding") != "" {
|
|
291
|
+
t.Errorf("Brave search forced Accept-Encoding instead of letting Go decode it: %q", req.Header.Get("Accept-Encoding"))
|
|
292
|
+
}
|
|
293
|
+
copy := req.Clone(req.Context())
|
|
294
|
+
copy.URL.Scheme = "http"
|
|
295
|
+
copy.URL.Host = strings.TrimPrefix(server.URL, "http://")
|
|
296
|
+
copy.Host = copy.URL.Host
|
|
297
|
+
return http.DefaultTransport.RoundTrip(copy)
|
|
298
|
+
})
|
|
299
|
+
t.Cleanup(func() { http.DefaultClient.Transport = originalTransport })
|
|
300
|
+
|
|
301
|
+
result, err := searchWithBrave(context.Background(), "Go docs")
|
|
302
|
+
if err != nil || len(result.Docs) != 1 || result.Docs[0].Title != "Guide" {
|
|
303
|
+
t.Fatalf("Brave gzip response not decoded: %v; %#v", err, result)
|
|
304
|
+
}
|
|
305
|
+
}
|
package/internal/app/scrape.go
CHANGED
|
@@ -21,7 +21,7 @@ type MarkdownResult struct {
|
|
|
21
21
|
const keychainServiceName = "webctx"
|
|
22
22
|
|
|
23
23
|
var (
|
|
24
|
-
credentialEnvKeys = []string{"BRAVE_API_KEY", "TAVILY_API_KEY", "EXA_API_KEY", "FIRECRAWL_API_KEY", "GH_TOKEN", "GITHUB_TOKEN"}
|
|
24
|
+
credentialEnvKeys = []string{"BRAVE_API_KEY", "BRAVE_API_KEYS", "TAVILY_API_KEY", "TAVILY_API_KEYS", "EXA_API_KEY", "EXA_API_KEYS", "FIRECRAWL_API_KEY", "GH_TOKEN", "GITHUB_TOKEN"}
|
|
25
25
|
getwdFunc = os.Getwd
|
|
26
26
|
executableFunc = os.Executable
|
|
27
27
|
keychainLookup = lookupKeychainSecret
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"io"
|
|
9
|
+
"net/url"
|
|
10
|
+
"os"
|
|
11
|
+
"os/exec"
|
|
12
|
+
"runtime"
|
|
13
|
+
"slices"
|
|
14
|
+
"strconv"
|
|
15
|
+
"strings"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// A provider's legacy singular key remains supported. Additional keys are
|
|
19
|
+
// checked first so newly provisioned keys take precedence over exhausted ones.
|
|
20
|
+
// Plural values can be comma/newline-separated or a JSON string array (the
|
|
21
|
+
// latter is how the macOS Keychain stores them).
|
|
22
|
+
func searchKeysForProvider(name string) []string {
|
|
23
|
+
name = strings.ToLower(strings.TrimSpace(name))
|
|
24
|
+
if !validSearchProvider(name) {
|
|
25
|
+
return nil
|
|
26
|
+
}
|
|
27
|
+
prefix := strings.ToUpper(name) + "_API_KEY"
|
|
28
|
+
keys := parseSearchKeyList(os.Getenv(prefix + "S"))
|
|
29
|
+
if key := strings.TrimSpace(os.Getenv(prefix)); key != "" {
|
|
30
|
+
keys = append(keys, key)
|
|
31
|
+
}
|
|
32
|
+
return uniqueSearchKeys(keys)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func parseSearchKeyList(raw string) []string {
|
|
36
|
+
raw = strings.TrimSpace(raw)
|
|
37
|
+
if raw == "" {
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
if strings.HasPrefix(raw, "[") {
|
|
41
|
+
var keys []string
|
|
42
|
+
if json.Unmarshal([]byte(raw), &keys) != nil {
|
|
43
|
+
return nil // fail closed rather than sending malformed credential data
|
|
44
|
+
}
|
|
45
|
+
return uniqueSearchKeys(keys)
|
|
46
|
+
}
|
|
47
|
+
return uniqueSearchKeys(strings.FieldsFunc(raw, func(r rune) bool {
|
|
48
|
+
return r == ',' || r == '\n' || r == '\r'
|
|
49
|
+
}))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func uniqueSearchKeys(keys []string) []string {
|
|
53
|
+
seen := make(map[string]bool, len(keys))
|
|
54
|
+
unique := make([]string, 0, len(keys))
|
|
55
|
+
for _, key := range keys {
|
|
56
|
+
key = strings.TrimSpace(key)
|
|
57
|
+
if key != "" && !seen[key] {
|
|
58
|
+
seen[key] = true
|
|
59
|
+
unique = append(unique, key)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return unique
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Retrying with another key is appropriate for auth, quota, rate-limit, and
|
|
66
|
+
// server/transport failures. Request validation errors or malformed successful
|
|
67
|
+
// responses won't improve with another credential and should not be retried.
|
|
68
|
+
func retryWithNextSearchKey(err error) bool {
|
|
69
|
+
var status *httpStatusError
|
|
70
|
+
if errors.As(err, &status) {
|
|
71
|
+
code := status.Code
|
|
72
|
+
// Brave Search rejects invalid subscription tokens with HTTP 422,
|
|
73
|
+
// rather than 401. Only retry that exact provider error code: a
|
|
74
|
+
// different 422 can indicate a bad query that no key can fix.
|
|
75
|
+
if code == 422 && strings.Contains(status.Message, "SUBSCRIPTION_TOKEN_INVALID") {
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
return code == 401 || code == 402 || code == 403 || code == 408 || code == 429 || code == 432 || code >= 500
|
|
79
|
+
}
|
|
80
|
+
var transport *url.Error
|
|
81
|
+
return errors.As(err, &transport)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func redactSearchError(err error, keys []string) error {
|
|
85
|
+
if err == nil {
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
message := err.Error()
|
|
89
|
+
for _, key := range keys {
|
|
90
|
+
if key != "" {
|
|
91
|
+
message = strings.ReplaceAll(message, key, "[REDACTED]")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return errors.New(message)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func useSearchKeys(ctx context.Context, name string, attempt func(string) ([]byte, error)) ([]byte, error) {
|
|
98
|
+
keys := searchKeysForProvider(name)
|
|
99
|
+
if len(keys) == 0 {
|
|
100
|
+
return nil, fmt.Errorf("missing %s_API_KEY", strings.ToUpper(name))
|
|
101
|
+
}
|
|
102
|
+
var lastErr error
|
|
103
|
+
for index, key := range keys {
|
|
104
|
+
if ctx.Err() != nil {
|
|
105
|
+
return nil, ctx.Err()
|
|
106
|
+
}
|
|
107
|
+
body, err := attempt(key)
|
|
108
|
+
if err == nil {
|
|
109
|
+
return body, nil
|
|
110
|
+
}
|
|
111
|
+
lastErr = redactSearchError(err, keys)
|
|
112
|
+
if index+1 == len(keys) || !retryWithNextSearchKey(err) {
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if len(keys) == 1 {
|
|
117
|
+
return nil, lastErr
|
|
118
|
+
}
|
|
119
|
+
return nil, fmt.Errorf("%s: search failed after trying available API keys (up to %d): %w", name, len(keys), lastErr)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
var keychainStore = storeKeychainSecret
|
|
123
|
+
|
|
124
|
+
// Additional keys are stored in one macOS Keychain entry per provider, without
|
|
125
|
+
// exposing secrets in argv, config.json, logs, or CLI output. The security CLI
|
|
126
|
+
// supports a prompt from stdin when -w is the final argument. It prompts for
|
|
127
|
+
// the password twice, even when updating an entry, so feed matching lines.
|
|
128
|
+
func storeKeychainSecret(account, secret string) error {
|
|
129
|
+
if runtime.GOOS != "darwin" {
|
|
130
|
+
return errors.New("key management requires macOS Keychain; on other platforms set PROVIDER_API_KEYS in the environment")
|
|
131
|
+
}
|
|
132
|
+
cmd := exec.Command("security", "add-generic-password", "-U", "-s", keychainServiceName, "-a", account, "-w")
|
|
133
|
+
cmd.Stdin = strings.NewReader(secret + "\n" + secret + "\n")
|
|
134
|
+
if err := cmd.Run(); err != nil {
|
|
135
|
+
return fmt.Errorf("could not save API keys to macOS Keychain: %w", err)
|
|
136
|
+
}
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func managedKeychainAccount(name string) (string, error) {
|
|
141
|
+
name = strings.ToLower(strings.TrimSpace(name))
|
|
142
|
+
if !validSearchProvider(name) {
|
|
143
|
+
return "", fmt.Errorf("unknown search provider %q (choose brave, tavily, or exa)", name)
|
|
144
|
+
}
|
|
145
|
+
return strings.ToUpper(name) + "_API_KEYS", nil
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func readManagedKeys(name string) ([]string, error) {
|
|
149
|
+
account, err := managedKeychainAccount(name)
|
|
150
|
+
if err != nil {
|
|
151
|
+
return nil, err
|
|
152
|
+
}
|
|
153
|
+
raw, err := keychainLookup(account)
|
|
154
|
+
if err != nil {
|
|
155
|
+
return nil, fmt.Errorf("could not read API keys from macOS Keychain: %w", err)
|
|
156
|
+
}
|
|
157
|
+
if raw == "" {
|
|
158
|
+
return nil, nil
|
|
159
|
+
}
|
|
160
|
+
var keys []string
|
|
161
|
+
if err := json.Unmarshal([]byte(raw), &keys); err != nil {
|
|
162
|
+
return nil, fmt.Errorf("the stored %s key list is not valid JSON; no keys were changed", name)
|
|
163
|
+
}
|
|
164
|
+
return uniqueSearchKeys(keys), nil
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func writeManagedKeys(name string, keys []string) error {
|
|
168
|
+
account, err := managedKeychainAccount(name)
|
|
169
|
+
if err != nil {
|
|
170
|
+
return err
|
|
171
|
+
}
|
|
172
|
+
serialized, err := json.Marshal(uniqueSearchKeys(keys))
|
|
173
|
+
if err != nil {
|
|
174
|
+
return err
|
|
175
|
+
}
|
|
176
|
+
return keychainStore(account, string(serialized))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func addManagedSearchKey(name string, from io.Reader) (int, error) {
|
|
180
|
+
if _, err := managedKeychainAccount(name); err != nil {
|
|
181
|
+
return 0, err
|
|
182
|
+
}
|
|
183
|
+
input, err := io.ReadAll(io.LimitReader(from, 4097))
|
|
184
|
+
if err != nil {
|
|
185
|
+
return 0, fmt.Errorf("read API key from stdin: %w", err)
|
|
186
|
+
}
|
|
187
|
+
key := strings.TrimSpace(string(input))
|
|
188
|
+
if key == "" || len(input) > 4096 || strings.ContainsAny(key, "\r\n\t ") {
|
|
189
|
+
return 0, errors.New("API key must be a single nonempty token no longer than 4096 bytes")
|
|
190
|
+
}
|
|
191
|
+
keys, err := readManagedKeys(name)
|
|
192
|
+
if err != nil {
|
|
193
|
+
return 0, err
|
|
194
|
+
}
|
|
195
|
+
if slices.Contains(keys, key) {
|
|
196
|
+
return len(keys), nil
|
|
197
|
+
}
|
|
198
|
+
// Newest keys take priority, so an old exhausted legacy key is never
|
|
199
|
+
// attempted before a recently added working key.
|
|
200
|
+
keys = append([]string{key}, keys...)
|
|
201
|
+
if err := writeManagedKeys(name, keys); err != nil {
|
|
202
|
+
return 0, err
|
|
203
|
+
}
|
|
204
|
+
return len(keys), nil
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func removeManagedSearchKey(name, rawIndex string) (int, error) {
|
|
208
|
+
keys, err := readManagedKeys(name)
|
|
209
|
+
if err != nil {
|
|
210
|
+
return 0, err
|
|
211
|
+
}
|
|
212
|
+
index, err := strconv.Atoi(rawIndex)
|
|
213
|
+
if err != nil || index < 1 || index > len(keys) {
|
|
214
|
+
return 0, fmt.Errorf("key index must be between 1 and %d (managed Keychain keys only)", len(keys))
|
|
215
|
+
}
|
|
216
|
+
keys = slices.Delete(keys, index-1, index)
|
|
217
|
+
if err := writeManagedKeys(name, keys); err != nil {
|
|
218
|
+
return 0, err
|
|
219
|
+
}
|
|
220
|
+
return len(keys), nil
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func printManagedSearchKeySlots(out io.Writer) error {
|
|
224
|
+
_, _ = fmt.Fprintln(out, "\nManaged macOS Keychain keys (numbered newest-first; indexes start at 1):")
|
|
225
|
+
for _, provider := range searchProviderKeys {
|
|
226
|
+
keys, err := readManagedKeys(provider.name)
|
|
227
|
+
if err != nil {
|
|
228
|
+
return err
|
|
229
|
+
}
|
|
230
|
+
_, _ = fmt.Fprintf(out, " %-7s %d managed\n", provider.name, len(keys))
|
|
231
|
+
}
|
|
232
|
+
return nil
|
|
233
|
+
}
|