webctx 0.2.1 → 0.2.3

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 CHANGED
@@ -4,7 +4,7 @@ Guidance for coding agents working in `webctx`.
4
4
 
5
5
  ## Purpose
6
6
 
7
- This repo contains the pure Go port of the `webctx` CLI.
7
+ This repo contains the Go `webctx` CLI and its hosted Origo MCP transport, sharing one retrieval engine.
8
8
 
9
9
  It is no longer a generic starter template. Treat the current CLI behavior as the source of truth unless the user explicitly asks to change it.
10
10
 
@@ -14,6 +14,9 @@ It is no longer a generic starter template. Treat the current CLI behavior as th
14
14
  - `internal/app/app.go`: CLI parsing and top-level command routing.
15
15
  - `internal/app/tools.go`: search provider clients, ranking, formatting, and HTTP helpers.
16
16
  - `internal/app/scrape.go`: GitHub raw-content optimization, `.md` fetch path, Firecrawl queue, and env loading.
17
+ - `pkg/retrieval/`: public adapter to `internal/app` for Vercel's Go packaging restrictions.
18
+ - `pkg/origo/`: authenticated Streamable HTTP MCP with exactly `read_link` and `map_site`.
19
+ - `api/mcp.go`: Go Function entrypoint for independent Origo deployment.
17
20
  - `internal/app/app_test.go`: unit tests for CLI behavior and core helpers.
18
21
  - `bin/webctx.js`: npm shim that invokes the packaged native binary.
19
22
  - `scripts/postinstall.js`: downloads release binary on install, falls back to `go build`.
@@ -51,7 +54,7 @@ Preserve these commands unless the user explicitly asks to change them:
51
54
  Behavioral expectations:
52
55
 
53
56
  - `search` combines Brave, Tavily, and Exa results, then re-ranks them with duplicate-aware scoring.
54
- - `read-link` keeps the current GitHub raw-content fast path, `.md` fast path, and Firecrawl fallback settings.
57
+ - `read-link` keeps the current GitHub raw-content and `.md` fast paths. Firecrawl uses its built-in 30-minute cache, automatic and enhanced proxies, then browser extraction as a final fallback.
55
58
  - `map-site` keeps the current Firecrawl map request settings.
56
59
  - The CLI should remain agent-friendly and emit plain markdown/text output.
57
60
 
@@ -71,7 +74,7 @@ Behavioral expectations:
71
74
 
72
75
  Release pipeline triggers on `v*` tags and expects:
73
76
 
74
- - `NPM_TOKEN` GitHub secret present.
77
+ - The `NPM_TOKEN` GitHub Actions secret must contain a valid npm access token with publish rights for `webctx`; publishing uses `NODE_AUTH_TOKEN` (not OIDC trusted publishing).
75
78
  - npm package name in `package.json` is publishable under your account/org.
76
79
  - repository URL matches the release origin used by `scripts/postinstall.js`.
77
80
 
@@ -81,7 +84,9 @@ Release binaries should embed the tagged version into `internal/buildinfo.Versio
81
84
 
82
85
  - Prefer additive changes and keep the CLI output stable.
83
86
  - Do not silently change Firecrawl request settings unless the user explicitly wants behavioral changes.
84
- - Do not reintroduce MCP/server code unless requested; this repo is intentionally CLI-only.
87
+ - Do not add search to Origo's MCP tool surface; only `read_link` and `map_site` are registered.
88
+ - Keep `origo-api` deployment separate from the existing WebCTX docs Vercel project and tag-driven CLI releases.
89
+ - Never introduce Redis, custom KV, or another caching service for Origo: the user explicitly chose Firecrawl's own 30-minute cache.
85
90
 
86
91
  ## Changelog Guidelines
87
92
 
package/CONTRIBUTORS.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Maintainer notes for `webctx`.
4
4
 
5
+ Origo is the separately deployed MCP transport in this same repository. The CLI, MCP, and ZueDocs share source code but have independent distribution pipelines.
6
+
5
7
  ## Prerequisites
6
8
 
7
9
  - Go `1.26+`
@@ -78,39 +80,38 @@ make release-tag VERSION=x.y.z
78
80
  - GitHub release publish
79
81
  - npm publish
80
82
 
81
- ## Required GitHub secret
82
-
83
- - `NPM_TOKEN`: npm automation token with publish rights for your package.
84
-
85
- Set via GitHub CLI:
86
-
87
- ```bash
88
- gh secret set NPM_TOKEN --repo amxv/webctx
89
- ```
90
-
91
- ## npm token setup
83
+ ## Automatic npm access-token publishing
92
84
 
93
- Create token at npm:
85
+ This project publishes npm releases using the repository's GitHub Actions secret `NPM_TOKEN`. Set it to a valid npm access token with publish access to the `webctx` package and appropriate npm two-factor-authentication settings for automated publishing. GitHub Actions supplies it as `NODE_AUTH_TOKEN` during the `release.yml` workflow's npm step.
94
86
 
95
- - Profile -> Access Tokens -> Create New Token
96
- - Use an automation/granular token scoped to required package/org
87
+ If a token expires, rotate it in npm and update `NPM_TOKEN` using `gh secret set NPM_TOKEN --repo amxv/webctx`. Never commit tokens or print them in job logs. Trusted publishing is not used.
97
88
 
98
- Validate auth locally:
89
+ If a release's GitHub binaries were successfully published while npm authentication failed, dispatch `npm-publish-retry.yml` with the existing version. Do not create or move the release tag just to retry npm publishing:
99
90
 
100
91
  ```bash
101
- npm whoami
92
+ gh workflow run npm-publish-retry.yml --repo amxv/webctx --ref main -f version=0.2.2
102
93
  ```
103
94
 
104
95
  ## Project layout
105
96
 
106
97
  - `cmd/webctx/main.go`: CLI entrypoint
107
98
  - `internal/app/`: CLI parsing, search, ranking, scrape, env loading, and Firecrawl queue logic
99
+ - `pkg/retrieval/`: small public adapter to the shared read-link / site-map engine
100
+ - `pkg/origo/`: authenticated, stateless MCP server with exactly two tools
101
+ - `api/mcp.go`: Vercel Go Function entrypoint for Origo
102
+ - `vercel.mjs`: project-specific Vercel settings for docs and Origo
108
103
  - `internal/buildinfo/`: build-time version plumbing for `--version`
109
104
  - `bin/webctx.js`: npm shim that invokes the packaged native binary
110
105
  - `scripts/postinstall.js`: downloads the release binary on install and falls back to local `go build`
111
106
  - `.github/workflows/release.yml`: tag-driven release pipeline
112
107
  - `AGENTS.md`: guidance for coding agents
113
108
 
109
+ ## Origo deployment
110
+
111
+ The `origo-api` Vercel project is linked to this GitHub repo and uses `ORIGO_DEPLOYMENT=1` to select its Go Function configuration. It needs `ORIGO_API_KEY` and `FIRECRAWL_API_KEY` production environment variables; optionally add `GH_TOKEN` for native authenticated GitHub reads. The docs project remains `webctx-docs` and never receives the Origo connection key.
112
+
113
+ Both projects deploy automatically from `main`. `scripts/should-build.mjs` gates docs and API builds independently, while `v*` GitHub tags still drive the CLI release workflow.
114
+
114
115
  ## Notes on package naming
115
116
 
116
117
  `webctx` is already configured. If you ever rename or move the package, update all of the following together:
package/README.md CHANGED
@@ -10,7 +10,22 @@ webctx read-link <url>
10
10
  webctx map-site <url>
11
11
  ```
12
12
 
13
- The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool.
13
+ The output is plain text or markdown, so it is easy to hand to ChatGPT, Codex, Claude Code, a shell script, or another tool. Search now retrieves relevant source excerpts automatically instead of returning only URLs. For a specific URL, add an optional question to explore related documentation and surface exact code and API details.
14
+
15
+ ```bash
16
+ webctx search "Go MCP SDK read-only tool annotations"
17
+ webctx read-link https://docs.example.com/api --question "How do authentication and pagination work?"
18
+ ```
19
+
20
+ ## Origo: hosted MCP access
21
+
22
+ **Origo** is the MCP transport over the same WebCTX retrieval engine. Connect an MCP client to `https://api.origo.ashray.xyz/mcp?key=<your-private-key>` to access exactly two tools: `read_link` and `map_site`. Origo does not expose `search`; the CLI continues to provide all three commands. `read_link` accepts an optional `question` to retrieve focused source context while keeping ordinary URL-only reads intact.
23
+
24
+ Both surfaces share native GitHub/Markdown readers and Firecrawl fallback behavior. Firecrawl's built-in cache is limited to **30 minutes**. Difficult pages automatically escalate through Firecrawl's proxy fallback and, if necessary, a temporary Browser Sandbox session. There is no Redis or additional cache database.
25
+
26
+ Source-aware reading also prefers raw JSON, YAML, and OpenAPI files when the URL identifies one; question-focused reads can follow up to two relevant pages on the same host, including links found in `llms.txt`, and inspect embedded structured JSON. Firecrawl Alexandria tool matching and search discovery are automatic when supported, but **no paid Alexandria provider tools are executed**. The original source URLs remain explicit so an agent can verify facts.
27
+
28
+ Origo deploys independently through the `origo-api` Vercel project on pushes to `main`. CLI releases and npm publishing are unchanged. See the [Origo MCP guide](https://webctx.ashray.xyz/docs/origo) for architecture, setup, and security notes.
14
29
 
15
30
  ## Install
16
31
 
@@ -63,7 +78,7 @@ Recognized native GitHub auth, private/not-found, and rate-limit failures stay a
63
78
 
64
79
  ## Search
65
80
 
66
- Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, and returns one useful list.
81
+ Normal search asks Brave, Tavily, and Exa, removes duplicate URLs, automatically reads three promising sources, follows up to two relevant links, and returns excerpts plus the ranked list. Alexandria tool discovery is included when available, without paid execution.
67
82
 
68
83
  ```bash
69
84
  webctx search "next.js server components"
package/go.mod CHANGED
@@ -1,3 +1,16 @@
1
1
  module github.com/amxv/webctx
2
2
 
3
3
  go 1.26
4
+
5
+ require github.com/modelcontextprotocol/go-sdk v1.8.0
6
+
7
+ require (
8
+ github.com/google/jsonschema-go v0.4.3 // indirect
9
+ github.com/segmentio/asm v1.1.3 // indirect
10
+ github.com/segmentio/encoding v0.5.4 // indirect
11
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
12
+ golang.org/x/oauth2 v0.35.0 // indirect
13
+ golang.org/x/sync v0.20.0 // indirect
14
+ golang.org/x/sys v0.41.0 // indirect
15
+ golang.org/x/time v0.15.0 // indirect
16
+ )
package/go.sum ADDED
@@ -0,0 +1,24 @@
1
+ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
2
+ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3
+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4
+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
5
+ github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
6
+ github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
7
+ github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
8
+ github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
9
+ github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
10
+ github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
11
+ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
12
+ github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
13
+ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
14
+ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
15
+ golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
16
+ golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
17
+ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
18
+ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
19
+ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
20
+ golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
21
+ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
22
+ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
23
+ golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
24
+ golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
@@ -0,0 +1,102 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "os"
9
+ "strings"
10
+ "time"
11
+ )
12
+
13
+ // formatAlexandriaTools describes discovered capabilities, not their data.
14
+ // Discovery is free but actual provider execution is not, and is never
15
+ // silently authorized by reading a link or searching the web.
16
+ func formatAlexandriaTools(raw any) string {
17
+ items, ok := raw.([]any)
18
+ if !ok || len(items) == 0 {
19
+ return ""
20
+ }
21
+ parts := []string{"**Available structured-data capabilities (not executed):**"}
22
+ count := 0
23
+ for _, item := range items {
24
+ tool, ok := item.(map[string]any)
25
+ if !ok {
26
+ continue
27
+ }
28
+ provider := safeToolField(tool["provider"], 80)
29
+ capability := safeToolField(tool["capability"], 160)
30
+ if provider == "" || capability == "" {
31
+ continue
32
+ }
33
+ description := safeToolField(tool["description"], 180)
34
+ if description == "" {
35
+ description = safeToolField(tool["name"], 180)
36
+ }
37
+ line := fmt.Sprintf("- `%s/%s`", provider, capability)
38
+ if description != "" {
39
+ line += ": " + description
40
+ }
41
+ parts = append(parts, line)
42
+ count++
43
+ if count >= 3 {
44
+ break
45
+ }
46
+ }
47
+ if count == 0 {
48
+ return ""
49
+ }
50
+ return strings.Join(parts, "\n")
51
+ }
52
+
53
+ func safeToolField(value any, limit int) string {
54
+ s, ok := value.(string)
55
+ if !ok {
56
+ return ""
57
+ }
58
+ s = strings.Join(strings.Fields(s), " ")
59
+ s = strings.ReplaceAll(s, "`", "'")
60
+ if len(s) > limit {
61
+ s = s[:limit] + "…"
62
+ }
63
+ return s
64
+ }
65
+
66
+ func withSourceTools(markdown, toolSummary string) string {
67
+ if toolSummary == "" {
68
+ return markdown
69
+ }
70
+ return markdown + "\n\n---\n\n" + toolSummary + "\n\n*Matched through Firecrawl Alexandria. Discovery does not verify, license, or execute these providers.*"
71
+ }
72
+
73
+ func discoverAlexandria(query string) string {
74
+ apiKey := strings.TrimSpace(os.Getenv("FIRECRAWL_API_KEY"))
75
+ if apiKey == "" {
76
+ return ""
77
+ }
78
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
79
+ defer cancel()
80
+ response, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/search", map[string]string{
81
+ "Authorization": "Bearer " + apiKey,
82
+ "Content-Type": "application/json",
83
+ }, map[string]any{
84
+ "query": query,
85
+ "sources": []string{"alexandria"},
86
+ "limit": 3,
87
+ "toolDetail": "compact",
88
+ })
89
+ if err != nil {
90
+ return "" // unavailable Alexandria is never a search failure
91
+ }
92
+ var result struct {
93
+ Success bool `json:"success"`
94
+ Data struct {
95
+ Tools []any `json:"tools"`
96
+ } `json:"data"`
97
+ }
98
+ if json.Unmarshal(response, &result) != nil || !result.Success {
99
+ return ""
100
+ }
101
+ return formatAlexandriaTools(result.Data.Tools)
102
+ }
@@ -51,10 +51,10 @@ func Run(args []string, stdout, stderr io.Writer) int {
51
51
  case "read-link":
52
52
  if strings.TrimSpace(input) == "" {
53
53
  _, _ = fmt.Fprintln(stderr, "Error: read-link requires a URL")
54
- _, _ = fmt.Fprintln(stdout, "Usage: webctx read-link <url>")
54
+ _, _ = fmt.Fprintln(stdout, "Usage: webctx read-link <url> [--question 'what to find']")
55
55
  return 1
56
56
  }
57
- text, err := ReadLink(input)
57
+ text, err := ReadLinkFocused(input, flags["question"])
58
58
  if err != nil {
59
59
  _, _ = fmt.Fprintln(stderr, err.Error())
60
60
  return 1
@@ -86,7 +86,7 @@ func usageText() string {
86
86
 
87
87
  Usage:
88
88
  webctx search <query> [--exclude domain1,domain2] [--keyword phrase]
89
- webctx read-link <url>
89
+ webctx read-link <url> [--question 'what to find']
90
90
  webctx map-site <url>
91
91
 
92
92
  Examples:
@@ -94,6 +94,7 @@ Examples:
94
94
  webctx search "react hooks" --exclude youtube.com,vimeo.com
95
95
  webctx search "drizzle orm" --keyword "migration guide"
96
96
  webctx read-link https://docs.example.com/guide
97
+ webctx read-link https://docs.example.com/api --question "How do auth and pagination work?"
97
98
  webctx map-site https://example.com`, version)
98
99
  }
99
100
 
@@ -0,0 +1,176 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/url"
9
+ "strconv"
10
+ "strings"
11
+ "time"
12
+ )
13
+
14
+ type scrapedPage struct {
15
+ Title string
16
+ Markdown string
17
+ ToolSummary string
18
+ }
19
+
20
+ func usableScrapedMarkdown(markdown string) bool {
21
+ text := strings.ToLower(strings.TrimSpace(markdown))
22
+ if len(text) < 10 {
23
+ return false
24
+ }
25
+ for _, marker := range []string{
26
+ "just a moment...", "checking your browser", "verify you are human",
27
+ "enable javascript and cookies", "access denied | cloudflare",
28
+ "attention required! | cloudflare", "no content extracted",
29
+ } {
30
+ if strings.Contains(text, marker) && len(text) < 2000 {
31
+ return false
32
+ }
33
+ }
34
+ return true
35
+ }
36
+
37
+ func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
38
+ requestBody := map[string]any{
39
+ "url": rawURL,
40
+ "formats": []string{"markdown"},
41
+ "domainTools": true, // Alexandria discovery, not paid execution.
42
+ "onlyMainContent": true,
43
+ "blockAds": true,
44
+ "removeBase64Images": true,
45
+ "maxAge": 30 * 60 * 1000, // Firecrawl's built-in cache, 30 minutes.
46
+ "excludeTags": []string{"script", "style", "meta", "noscript", "svg", "img", "nav", "footer", "header", "aside", ".advertisement", "#ad"},
47
+ }
48
+ if proxy != "" {
49
+ requestBody["proxy"] = proxy
50
+ // A blocked cached result must not mask an actual enhanced retry.
51
+ requestBody["maxAge"] = 0
52
+ }
53
+ if strings.HasSuffix(strings.ToLower(rawURL), ".pdf") {
54
+ requestBody["parsers"] = []string{"pdf"}
55
+ }
56
+ data, err := getFirecrawlQueue().enqueue(rawURL, func() (map[string]any, error) {
57
+ ctx, cancel := context.WithTimeout(context.Background(), 32*time.Second)
58
+ defer cancel()
59
+ body, err := doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/scrape", map[string]string{
60
+ "Authorization": "Bearer " + apiKey,
61
+ "Content-Type": "application/json",
62
+ }, requestBody)
63
+ // Alexandria is a separately enabled Firecrawl capability. A 403 from
64
+ // domain matching must never break ordinary page extraction for users that
65
+ // haven't enabled Alexandria or have zero-data-retention configured.
66
+ if err != nil && strings.Contains(err.Error(), "403") {
67
+ requestBody["domainTools"] = false
68
+ body, err = doJSONRequest(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/scrape", map[string]string{
69
+ "Authorization": "Bearer " + apiKey,
70
+ "Content-Type": "application/json",
71
+ }, requestBody)
72
+ }
73
+ if err != nil {
74
+ return nil, err
75
+ }
76
+ var parsed map[string]any
77
+ if err := json.Unmarshal(body, &parsed); err != nil {
78
+ return nil, fmt.Errorf("decode Firecrawl scrape: %w", err)
79
+ }
80
+ if success, _ := parsed["success"].(bool); !success {
81
+ return nil, fmt.Errorf("Firecrawl scrape failed: %v", parsed["error"])
82
+ }
83
+ return parsed, nil
84
+ })
85
+ if err != nil {
86
+ return scrapedPage{}, err
87
+ }
88
+ pageData, _ := data["data"].(map[string]any)
89
+ metadata, _ := pageData["metadata"].(map[string]any)
90
+ page := scrapedPage{
91
+ Title: stringValue(metadata["title"]),
92
+ Markdown: stringValue(pageData["markdown"]),
93
+ ToolSummary: formatAlexandriaTools(pageData["tools"]),
94
+ }
95
+ if status, ok := metadata["statusCode"].(float64); ok && status >= 400 {
96
+ return page, fmt.Errorf("source responded with HTTP %d", int(status))
97
+ }
98
+ return page, nil
99
+ }
100
+
101
+ func firecrawlJSON(ctx context.Context, method, endpoint, apiKey string, data map[string]any) (map[string]any, error) {
102
+ body, err := doJSONRequest(ctx, method, endpoint, map[string]string{
103
+ "Authorization": "Bearer " + apiKey,
104
+ "Content-Type": "application/json",
105
+ }, data)
106
+ if err != nil {
107
+ return nil, err
108
+ }
109
+ var parsed map[string]any
110
+ if err := json.Unmarshal(body, &parsed); err != nil {
111
+ return nil, err
112
+ }
113
+ if success, ok := parsed["success"].(bool); ok && !success {
114
+ return nil, fmt.Errorf("Firecrawl browser: %v", parsed["error"])
115
+ }
116
+ return parsed, nil
117
+ }
118
+
119
+ // quoteShellURL escapes a validated URL for the Firecrawl sandbox's bash mode.
120
+ // This invokes only the installed agent-browser CLI, never model-written code.
121
+ func quoteShellURL(rawURL string) string {
122
+ return "'" + strings.ReplaceAll(rawURL, "'", "'\\''") + "'"
123
+ }
124
+
125
+ func firecrawlBrowserFallback(rawURL, apiKey string) (string, string, error) {
126
+ ctx, cancel := context.WithTimeout(context.Background(), 28*time.Second)
127
+ defer cancel()
128
+ // Failed scrape responses do not reliably include a scrape-bound browser ID.
129
+ // Open a disposable session with the standalone browser API instead.
130
+ session, err := firecrawlJSON(ctx, http.MethodPost, "https://api.firecrawl.dev/v2/interact", apiKey, map[string]any{
131
+ "ttl": 60, "activityTtl": 30,
132
+ })
133
+ if err != nil {
134
+ return "", "", err
135
+ }
136
+ sessionID := stringValue(session["id"])
137
+ if sessionID == "" {
138
+ return "", "", fmt.Errorf("Firecrawl browser returned no session ID")
139
+ }
140
+ endpoint := "https://api.firecrawl.dev/v2/interact/" + url.PathEscape(sessionID)
141
+ defer stopFirecrawlBrowser(endpoint, apiKey)
142
+ result, err := firecrawlJSON(ctx, http.MethodPost, endpoint+"/execute", apiKey, map[string]any{
143
+ // The sandbox's agent-browser does not expose a "scrape" subcommand
144
+ // even though Firecrawl's examples use it. DOM innerText is supported.
145
+ // Suppress navigation's banner so stdout contains only the evaluated text.
146
+ "code": "agent-browser open " + quoteShellURL(rawURL) + " >/dev/null && agent-browser eval 'document.body.innerText'",
147
+ "language": "bash",
148
+ "timeout": 20,
149
+ })
150
+ if err != nil {
151
+ return "", "", err
152
+ }
153
+ if code, ok := result["exitCode"].(float64); ok && code != 0 {
154
+ return "", "", fmt.Errorf("browser command exited with %d", int(code))
155
+ }
156
+ text := stringValue(result["stdout"])
157
+ if text == "" {
158
+ text = stringValue(result["output"])
159
+ }
160
+ if text == "" {
161
+ text = stringValue(result["result"])
162
+ }
163
+ // agent-browser eval serializes JS strings as JSON with surrounding quotes.
164
+ // Decode those escapes rather than handing the model a JSON string literal.
165
+ if parsed, err := strconv.Unquote(strings.TrimSpace(text)); err == nil {
166
+ text = parsed
167
+ }
168
+ return "Browser extraction", text, nil
169
+ }
170
+
171
+ func stopFirecrawlBrowser(endpoint, apiKey string) {
172
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
173
+ defer cancel()
174
+ // Best-effort cleanup regardless of whether extraction succeeded.
175
+ _, _ = firecrawlJSON(ctx, http.MethodDelete, endpoint, apiKey, nil)
176
+ }
@@ -0,0 +1,83 @@
1
+ package app
2
+
3
+ import (
4
+ "io"
5
+ "net/http"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func TestSourceURLSafety(t *testing.T) {
11
+ for _, raw := range []string{"file:///etc/passwd", "http://localhost:9999", "http://127.0.0.1:80", "http://[::1]", "http://user:pass@example.com", "http://192.168.0.1", "http://169.254.169.254"} {
12
+ if validateSourceURL(raw) == nil {
13
+ t.Fatalf("allowed unsafe URL %q", raw)
14
+ }
15
+ }
16
+ if err := validateSourceURL("https://example.com/guide"); err != nil {
17
+ t.Fatalf("rejected public URL: %v", err)
18
+ }
19
+ }
20
+
21
+ func TestScrapeEscalatesToEnhancedOnBlockedBody(t *testing.T) {
22
+ originalClient := http.DefaultClient
23
+ t.Cleanup(func() { http.DefaultClient = originalClient })
24
+ var proxies []string
25
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
26
+ if req.Method == http.MethodPost && req.URL.String() == "https://api.firecrawl.dev/v2/scrape" {
27
+ body, _ := io.ReadAll(req.Body)
28
+ if strings.Contains(string(body), `"proxy":"enhanced"`) {
29
+ proxies = append(proxies, "enhanced")
30
+ if !strings.Contains(string(body), `"maxAge":0`) {
31
+ t.Errorf("enhanced retry must bypass blocked cached results: %s", body)
32
+ }
33
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"data":{"metadata":{"title":"Article"},"markdown":"# Article\n\nLonger useful body from enhanced proxy."}}`, nil), nil
34
+ }
35
+ proxies = append(proxies, "auto")
36
+ if !strings.Contains(string(body), `"maxAge":1800000`) {
37
+ t.Errorf("Firecrawl cache age not 30 minutes: %s", body)
38
+ }
39
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"data":{"markdown":"Just a moment... checking your browser"}}`, nil), nil
40
+ }
41
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
42
+ })}
43
+ t.Setenv("FIRECRAWL_API_KEY", "test")
44
+ _, markdown, err := scrapeLinkWithFirecrawl("https://example.com/article")
45
+ if err != nil || !strings.Contains(markdown, "Longer useful body") {
46
+ t.Fatalf("expected enhanced proxy content, got %q: %v", markdown, err)
47
+ }
48
+ if strings.Join(proxies, ",") != "auto,enhanced" {
49
+ t.Fatalf("unexpected proxy stages %v", proxies)
50
+ }
51
+ }
52
+
53
+ func TestScrapeEscalatesToBrowserWhenScrapeFails(t *testing.T) {
54
+ originalClient := http.DefaultClient
55
+ t.Cleanup(func() { http.DefaultClient = originalClient })
56
+ var calls []string
57
+ http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
58
+ calls = append(calls, req.Method+" "+req.URL.Path)
59
+ switch req.Method + " " + req.URL.Path {
60
+ case "POST /v2/scrape":
61
+ return testHTTPResponse(req, http.StatusOK, `{"success":false,"error":"blocked"}`, nil), nil
62
+ case "POST /v2/interact":
63
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"id":"abc123"}`, nil), nil
64
+ case "POST /v2/interact/abc123/execute":
65
+ body, _ := io.ReadAll(req.Body)
66
+ if !strings.Contains(string(body), "agent-browser eval") || strings.Contains(string(body), "agent-browser scrape") {
67
+ t.Errorf("missing browser extraction command: %s", body)
68
+ }
69
+ return testHTTPResponse(req, http.StatusOK, `{"success":true,"stdout":"\"Real page content from browser.\\nNew paragraph.\"","exitCode":0}`, nil), nil
70
+ case "DELETE /v2/interact/abc123":
71
+ return testHTTPResponse(req, http.StatusOK, `{}`, nil), nil
72
+ }
73
+ return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
74
+ })}
75
+ t.Setenv("FIRECRAWL_API_KEY", "test")
76
+ _, markdown, err := scrapeLinkWithFirecrawl("https://example.com/dynamic")
77
+ if err != nil || !strings.Contains(markdown, "Real page content") || !strings.Contains(markdown, "browser.\nNew paragraph.") {
78
+ t.Fatalf("expected browser fallback content, got %q: %v", markdown, err)
79
+ }
80
+ if strings.Join(calls, ",") != "POST /v2/scrape,POST /v2/interact,POST /v2/interact/abc123/execute,DELETE /v2/interact/abc123" {
81
+ t.Fatalf("unexpected browser lifecycle: %v", calls)
82
+ }
83
+ }
@@ -759,11 +759,10 @@ func TestReadLinkUnsupportedGitHubFallsThroughToFirecrawlWithExistingSettings(t
759
759
  t.Fatalf("unsupported GitHub route was swallowed: %q", out)
760
760
  }
761
761
  for key, want := range map[string]any{
762
- "onlyMainContent": true,
763
- "skipTlsVerification": true,
764
- "blockAds": true,
765
- "removeBase64Images": true,
766
- "maxAge": float64(600000),
762
+ "onlyMainContent": true,
763
+ "blockAds": true,
764
+ "removeBase64Images": true,
765
+ "maxAge": float64(1800000),
767
766
  } {
768
767
  if got := firecrawlPayload[key]; got != want {
769
768
  t.Errorf("Firecrawl setting %s changed: got %#v want %#v", key, got, want)