@foldrun/core 0.4.0 → 0.6.0
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/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/providers.ts
CHANGED
|
@@ -58,21 +58,353 @@ export interface ProviderPreset {
|
|
|
58
58
|
note?: string;
|
|
59
59
|
/** A tool loop was driven through this endpoint from this runtime. */
|
|
60
60
|
verified?: boolean;
|
|
61
|
+
/** Whether the endpoint runs a server-side web search, and in whose shape.
|
|
62
|
+
* Absent means it does not — `web.search: <that name>` is then an error a
|
|
63
|
+
* person should see from `check`, not an empty result at 3am. */
|
|
64
|
+
search?: SearchShape;
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/** Whether this endpoint will execute a server-side web search, and in whose
|
|
68
|
+
* shape. Separate from `format` on purpose: speaking a wire is not the same
|
|
69
|
+
* as running a tool on it. DeepSeek's endpoint is Anthropic-shaped and still
|
|
70
|
+
* has no search at all, which is exactly the mistake this field exists to
|
|
71
|
+
* stop `check` from letting through.
|
|
72
|
+
*
|
|
73
|
+
* "anthropic" executes the `web_search_20250305` server tool as Anthropic
|
|
74
|
+
* defines it, so the runtime grants it and the endpoint answers
|
|
75
|
+
* "plugin" has its own switch rather than a tool (OpenRouter's web
|
|
76
|
+
* plugin / `:online`), fulfilled natively where the underlying
|
|
77
|
+
* model supports it and by Exa everywhere else
|
|
78
|
+
* "openai" the Responses-shaped `web_search` tool
|
|
79
|
+
* "builtin_fn" Moonshot's `$web_search`, a Chat-Completions builtin_function
|
|
80
|
+
* the client has to echo back — billed per successful call
|
|
81
|
+
* undefined no server-side search. Ours is the only way to the web. */
|
|
82
|
+
export type SearchShape = "anthropic" | "plugin" | "openai" | "builtin_fn" | "direct";
|
|
83
|
+
|
|
84
|
+
/** A search API the runtime calls itself, with the customer's own key.
|
|
85
|
+
*
|
|
86
|
+
* These are not model providers — none of them serves a model — so they
|
|
87
|
+
* live apart from PROVIDERS. `web.search:` accepts either kind of name, and
|
|
88
|
+
* the difference decides where the search runs: a provider's server-side
|
|
89
|
+
* tool runs on the provider's machines and is off the run record, while a
|
|
90
|
+
* direct API is called from the run's own sandbox through the egress proxy
|
|
91
|
+
* — the key never enters the pod, the call is on the record with its
|
|
92
|
+
* arguments, and it works whichever model is driving. Same switch, better
|
|
93
|
+
* audit trail. Shapes were read from each vendor's own API reference on
|
|
94
|
+
* 2026-09-16; the gallery's web tool carries the
|
|
95
|
+
* matching request and response mapping. */
|
|
96
|
+
export interface SearchApi {
|
|
97
|
+
/** The vendor's API reference the adapter was built from, the day it was
|
|
98
|
+
* last matched to it, and what that page could not confirm. Read by
|
|
99
|
+
* web-actions.ts; a check older than 90 days fails its test. */
|
|
100
|
+
docs?: string;
|
|
101
|
+
checked?: string;
|
|
102
|
+
gaps?: string;
|
|
103
|
+
name: string;
|
|
104
|
+
aliases?: string[];
|
|
105
|
+
title: string;
|
|
106
|
+
/** The one host the key may be sent to — the egress grant is for this. */
|
|
107
|
+
host: string;
|
|
108
|
+
endpoint: string;
|
|
109
|
+
method: "GET" | "POST";
|
|
110
|
+
/** The header the key travels in, and any prefix on the value. */
|
|
111
|
+
auth: { header: string; prefix?: string };
|
|
112
|
+
/** The vault name a customer stores their key under. */
|
|
113
|
+
secret: string;
|
|
114
|
+
index: string;
|
|
115
|
+
note?: string;
|
|
116
|
+
/** The API answers without a key (at a lower rate). The runner declares
|
|
117
|
+
* the secret only when the vault has it, so a missing key is not an
|
|
118
|
+
* error the way a declared-and-absent secret is. */
|
|
119
|
+
secretOptional?: boolean;
|
|
120
|
+
/** What the value in the vault must be, when it is not the bare key. */
|
|
121
|
+
secretFormat?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const SEARCH_APIS: readonly SearchApi[] = [
|
|
125
|
+
{ name: "brave", docs: "https://api-dashboard.search.brave.com/app/documentation/web-search/query", checked: "2026-09-29", title: "Brave Search", host: "api.search.brave.com", endpoint: "https://api.search.brave.com/res/v1/web/search",
|
|
126
|
+
method: "GET", auth: { header: "X-Subscription-Token" }, secret: "BRAVE_SEARCH_API_KEY",
|
|
127
|
+
index: "Brave's own — ~40B pages, ~100M refreshed a day; the index Claude searches" },
|
|
128
|
+
{ name: "exa", docs: "https://exa.ai/docs/reference/search", checked: "2026-09-29", title: "Exa", host: "api.exa.ai", endpoint: "https://api.exa.ai/search",
|
|
129
|
+
method: "POST", auth: { header: "x-api-key" }, secret: "EXA_API_KEY",
|
|
130
|
+
index: "Exa's own semantic index — by meaning, not keywords" },
|
|
131
|
+
{ name: "tavily", docs: "https://docs.tavily.com/documentation/api-reference/endpoint/search", checked: "2026-09-29", title: "Tavily", host: "api.tavily.com", endpoint: "https://api.tavily.com/search",
|
|
132
|
+
method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "TAVILY_API_KEY",
|
|
133
|
+
index: "Tavily's own crawler plus bought-in feeds" },
|
|
134
|
+
{ name: "parallel", docs: "https://docs.parallel.ai/api-reference/search-api/search", checked: "2026-09-29", title: "Parallel", host: "api.parallel.ai", endpoint: "https://api.parallel.ai/v1/search",
|
|
135
|
+
method: "POST", auth: { header: "x-api-key" }, secret: "PARALLEL_API_KEY",
|
|
136
|
+
index: "Parallel's own closed index",
|
|
137
|
+
note: "Wants an objective beside the queries; the wrapper writes one from the query." },
|
|
138
|
+
{ name: "you", docs: "https://you.com/docs/api-reference/search/v1-search", checked: "2026-09-29", aliases: ["youcom", "you.com"], title: "You.com", host: "ydc-index.io", endpoint: "https://ydc-index.io/v1/search",
|
|
139
|
+
method: "POST", auth: { header: "X-API-Key" }, secret: "YOU_API_KEY",
|
|
140
|
+
index: "You.com's own index and cache (self-reported)" },
|
|
141
|
+
{ name: "jina", docs: "https://s.jina.ai/docs", checked: "2026-09-29", gaps: "response fields (title, url, description, content) are not in any official schema; read from live responses", title: "Jina Search", host: "s.jina.ai", endpoint: "https://s.jina.ai/",
|
|
142
|
+
method: "GET", auth: { header: "Authorization", prefix: "Bearer " }, secret: "JINA_API_KEY",
|
|
143
|
+
index: "Jina's — top results, each with its page content already read",
|
|
144
|
+
note: "s.jina.ai refuses without a key (checked live 2026-09-16); r.jina.ai, the reader, does not." },
|
|
145
|
+
{ name: "firecrawl", docs: "https://docs.firecrawl.dev/api-reference/endpoint/search", checked: "2026-09-29", title: "Firecrawl", host: "api.firecrawl.dev", endpoint: "https://api.firecrawl.dev/v2/search",
|
|
146
|
+
method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "FIRECRAWL_API_KEY",
|
|
147
|
+
index: "Firecrawl's — open-source core, self-hostable" },
|
|
148
|
+
{ name: "perplexity", docs: "https://docs.perplexity.ai/api-reference/search-post", checked: "2026-09-29", title: "Perplexity Search", host: "api.perplexity.ai", endpoint: "https://api.perplexity.ai/search",
|
|
149
|
+
method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "PERPLEXITY_API_KEY",
|
|
150
|
+
index: "Perplexity's own crawl — the Search API returns results, not an answer" },
|
|
151
|
+
{ name: "linkup", docs: "https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-search", checked: "2026-09-29", title: "Linkup", host: "api.linkup.so", endpoint: "https://api.linkup.so/v1/search",
|
|
152
|
+
method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "LINKUP_API_KEY",
|
|
153
|
+
index: "Linkup's — agent-shaped, each result with its content" },
|
|
154
|
+
// ---- the SERP scrapers: Google's own results page, read for you. Not
|
|
155
|
+
// an index of their own, and not Google partners — there is no such
|
|
156
|
+
// programme. The one kind to use when the question is about Google
|
|
157
|
+
// itself: where a page ranks, what the SERP shows.
|
|
158
|
+
{ name: "serper", docs: "https://serper.dev", checked: "2026-09-29", gaps: "no public API reference page; parameters and fields confirmed from the homepage only", title: "Serper", host: "google.serper.dev", endpoint: "https://google.serper.dev/search",
|
|
159
|
+
method: "POST", auth: { header: "X-API-KEY" }, secret: "SERPER_API_KEY",
|
|
160
|
+
index: "Google's results page, scraped — ~$1 / 1,000; fast, developers' favourite" },
|
|
161
|
+
{ name: "serpapi", docs: "https://serpapi.com/search-api", checked: "2026-09-29", title: "SerpApi", host: "serpapi.com", endpoint: "https://serpapi.com/search",
|
|
162
|
+
method: "GET", auth: { header: "", prefix: "" }, secret: "SERPAPI_API_KEY",
|
|
163
|
+
index: "Google's results page, scraped — the dearest, 80+ engines",
|
|
164
|
+
note: "The key travels as the api_key query parameter; the egress proxy fills the URL as it fills a header." },
|
|
165
|
+
{ name: "dataforseo", docs: "https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/", checked: "2026-09-29", title: "DataForSEO", host: "api.dataforseo.com", endpoint: "https://api.dataforseo.com/v3/serp/google/organic/live/advanced",
|
|
166
|
+
method: "POST", auth: { header: "Authorization", prefix: "Basic " }, secret: "DATAFORSEO_AUTH_BASIC",
|
|
167
|
+
secretFormat: "base64 of `login:password` — DataForSEO authenticates with HTTP basic auth, and the proxy fills one placeholder verbatim: `printf 'LOGIN:PASSWORD' | base64`",
|
|
168
|
+
index: "Google's results page, scraped — $0.60 / 1,000 standard queue; what rank-desk uses" },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/** Names people will try that cannot work, and why — said at `check`
|
|
172
|
+
* rather than discovered at 3am. */
|
|
173
|
+
export const REFUSED_WEB: Record<string, string> = {
|
|
174
|
+
bing: "Microsoft retired the Bing Search API on 11 Aug 2025. What remains is Grounding with Bing Search, usable only inside an Azure AI agent — not a search a tool can call. Bing's index still answers through ChatGPT (web.search: openai).",
|
|
175
|
+
azure: "the Bing Search API is retired; Grounding with Bing Search runs only inside Azure AI agents. See bing.",
|
|
176
|
+
google: "Google does not sell its index. The Custom Search JSON API is closed to new customers and retires 1 Jan 2027; Grounding with Google Search runs only inside Gemini. For Google's results page, use a SERP scraper: serper, serpapi or dataforseo.",
|
|
177
|
+
apify: "Apify is an actor marketplace, not a search, fetch or browser endpoint. Reach a specific actor as an http tool file, the way blog-desk's apify-fallback does.",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Fetch APIs the runtime calls itself: a URL in, the page out, with the
|
|
181
|
+
* customer's own key. Same seam as the search APIs — `web.fetch: jina` —
|
|
182
|
+
* and the same trade: our own fetch is free and on the record; these are
|
|
183
|
+
* for the failure modes ours cannot cover, chiefly a page that refuses a
|
|
184
|
+
* plain request. Three tiers, priced accordingly: a reader (Jina,
|
|
185
|
+
* Firecrawl) turns a page into clean markdown; a search vendor's extract
|
|
186
|
+
* (Exa, Tavily, Parallel) reads many at once; an unblocker (Zyte, ScrapingBee) renders
|
|
187
|
+
* behind the anti-bot walls a reader cannot pass. */
|
|
188
|
+
export interface FetchApi extends Omit<SearchApi, "index"> {
|
|
189
|
+
tier: "reader" | "extract" | "unblocker";
|
|
190
|
+
/** How many URLs one call may carry. 1 means one call per page. */
|
|
191
|
+
batch: number;
|
|
192
|
+
what: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const FETCH_APIS: readonly FetchApi[] = [
|
|
196
|
+
{ name: "jina", docs: "https://jina.ai/reader/", checked: "2026-09-29", gaps: "the {code, status, data} JSON wrapper is not shown on an official page", title: "Jina Reader", host: "r.jina.ai", endpoint: "https://r.jina.ai/",
|
|
197
|
+
method: "GET", auth: { header: "Authorization", prefix: "Bearer " }, secret: "JINA_API_KEY", secretOptional: true,
|
|
198
|
+
tier: "reader", batch: 1, what: "clean markdown, text or html; works without a key at 20 requests a minute (checked live 2026-09-16)" },
|
|
199
|
+
{ name: "firecrawl", docs: "https://docs.firecrawl.dev/api-reference/endpoint/scrape", checked: "2026-09-29", title: "Firecrawl", host: "api.firecrawl.dev", endpoint: "https://api.firecrawl.dev/v2/scrape",
|
|
200
|
+
method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "FIRECRAWL_API_KEY",
|
|
201
|
+
tier: "reader", batch: 1, what: "main-content markdown or html, boilerplate stripped; one URL per call" },
|
|
202
|
+
{ name: "exa", docs: "https://exa.ai/docs/reference/get-contents", checked: "2026-09-29", title: "Exa Contents", host: "api.exa.ai", endpoint: "https://api.exa.ai/contents",
|
|
203
|
+
method: "POST", auth: { header: "x-api-key" }, secret: "EXA_API_KEY",
|
|
204
|
+
tier: "extract", batch: 100, what: "text for up to 100 URLs in one call; always fetched fresh (maxAgeHours: 0), not Exa's cache" },
|
|
205
|
+
{ name: "tavily", docs: "https://docs.tavily.com/documentation/api-reference/endpoint/extract", checked: "2026-09-29", title: "Tavily Extract", host: "api.tavily.com", endpoint: "https://api.tavily.com/extract",
|
|
206
|
+
method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "TAVILY_API_KEY",
|
|
207
|
+
tier: "extract", batch: 20, what: "markdown or text for up to 20 URLs in one call, failures listed beside successes" },
|
|
208
|
+
{ name: "parallel", docs: "https://docs.parallel.ai/api-reference/extract/extract", checked: "2026-09-29", title: "Parallel Extract", host: "api.parallel.ai", endpoint: "https://api.parallel.ai/v1/extract",
|
|
209
|
+
method: "POST", auth: { header: "x-api-key" }, secret: "PARALLEL_API_KEY",
|
|
210
|
+
tier: "extract", batch: 20, what: "full-page markdown for several URLs at once; handles JavaScript pages and PDFs" },
|
|
211
|
+
{ name: "zyte", docs: "https://docs.zyte.com/zyte-api/usage/reference.html", checked: "2026-09-29", title: "Zyte API", host: "api.zyte.com", endpoint: "https://api.zyte.com/v1/extract",
|
|
212
|
+
method: "POST", auth: { header: "Authorization", prefix: "Basic " }, secret: "ZYTE_API_KEY_BASIC",
|
|
213
|
+
secretFormat: "base64 of `<api key>:` — Zyte authenticates with HTTP basic auth, and the proxy fills a placeholder verbatim, so the vault holds the encoded form: `printf 'KEY:' | base64`",
|
|
214
|
+
tier: "unblocker", batch: 1, what: "the page rendered in a real browser behind Zyte's proxy pool — for the sites that refuse everything else; pay per successful request" },
|
|
215
|
+
{ name: "scrapingbee", docs: "https://www.scrapingbee.com/documentation/", checked: "2026-09-29", aliases: ["scraping-bee", "scraping_bee"], title: "ScrapingBee", host: "app.scrapingbee.com", endpoint: "https://app.scrapingbee.com/api/v1/",
|
|
216
|
+
method: "GET", auth: { header: "Authorization", prefix: "Bearer " }, secret: "SCRAPINGBEE_API_KEY",
|
|
217
|
+
tier: "unblocker", batch: 1, what: "the page rendered in a real browser (render_js) behind ScrapingBee's proxy pool; credits per call, more for JavaScript" },
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
/** Remote browsers: a CDP endpoint our web browse connects to instead of
|
|
221
|
+
* the account's pod — the same tool, the same modes and actions, rendered
|
|
222
|
+
* on the vendor's machines. `web.browse: browserbase`. The one capability
|
|
223
|
+
* where the key cannot ride the egress proxy: CDP is a websocket, so the
|
|
224
|
+
* wrapper holds the real value the way it already holds a cookie secret,
|
|
225
|
+
* and creates the session itself where the vendor wants one. */
|
|
226
|
+
/** What a vendor's own session can be asked for, per its docs — the keys of
|
|
227
|
+
* `web.browse.vendor_session:` it accepts. Absent means it has no such option;
|
|
228
|
+
* naming one is an error in `check` that says which vendors do. */
|
|
229
|
+
export interface SessionSupport {
|
|
230
|
+
/** on: a proxy can be switched on; always: it is always on (true is a
|
|
231
|
+
* no-op, false an error); the rest say which parts of a location, and
|
|
232
|
+
* whether a proxy of your own is taken. */
|
|
233
|
+
proxy?: { on?: boolean; always?: boolean; country?: boolean; state?: boolean; city?: boolean; own?: boolean; needsCountry?: boolean; stateOrCity?: boolean };
|
|
234
|
+
captcha?: boolean;
|
|
235
|
+
stealth?: boolean;
|
|
236
|
+
region?: readonly string[];
|
|
237
|
+
/** The session's life, in seconds: [least, most]. */
|
|
238
|
+
timeout?: readonly [number, number];
|
|
239
|
+
/** Saved logins across runs: the vendor's contexts or profiles. */
|
|
240
|
+
keep?: boolean;
|
|
241
|
+
record?: boolean;
|
|
242
|
+
block?: readonly ("ads" | "trackers" | "cookies")[];
|
|
243
|
+
/** Where `options:` goes, as written: the session request's body, or the
|
|
244
|
+
* connection URL's query. Absent: the vendor takes none. */
|
|
245
|
+
options?: "body" | "query";
|
|
246
|
+
/** web.browse settings this vendor's browser will not take. */
|
|
247
|
+
refuses?: readonly string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** `web.browse.vendor_session:` — what the vendor's session is asked for.
|
|
251
|
+
* Called `session:` until 2026-10-01, which a call's `session=` (a named
|
|
252
|
+
* saved login) also is; the old key is still read, and check warns. */
|
|
253
|
+
export interface BrowseSession {
|
|
254
|
+
proxy?: boolean | { country?: string; state?: string; city?: string; own?: string };
|
|
255
|
+
captcha?: boolean;
|
|
256
|
+
stealth?: boolean;
|
|
257
|
+
region?: string;
|
|
258
|
+
/** seconds */
|
|
259
|
+
timeout?: number;
|
|
260
|
+
keep?: string;
|
|
261
|
+
record?: boolean;
|
|
262
|
+
block?: ("ads" | "trackers" | "cookies")[];
|
|
263
|
+
options?: Record<string, unknown>;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface BrowserApi {
|
|
267
|
+
session?: SessionSupport;
|
|
268
|
+
/** Steps of web browse this browser cannot do, per the vendor's own docs.
|
|
269
|
+
* Kept equal to the tool's VENDORS table by the platform's gallery test. */
|
|
270
|
+
lacks?: string[];
|
|
271
|
+
/** The vendor's API reference the adapter was built from, the day it was
|
|
272
|
+
* last matched to it, and what that page could not confirm. Read by
|
|
273
|
+
* web-actions.ts; a check older than 90 days fails its test. */
|
|
274
|
+
docs?: string;
|
|
275
|
+
checked?: string;
|
|
276
|
+
gaps?: string;
|
|
277
|
+
name: string;
|
|
278
|
+
aliases?: string[];
|
|
279
|
+
title: string;
|
|
280
|
+
/** How a session is reached: a REST call that returns the endpoint, or a
|
|
281
|
+
* websocket URL the key goes straight into. */
|
|
282
|
+
how: "session" | "direct";
|
|
283
|
+
host: string;
|
|
284
|
+
secret: string;
|
|
285
|
+
secretFormat?: string;
|
|
286
|
+
what: string;
|
|
287
|
+
note?: string;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export const BROWSER_APIS: readonly BrowserApi[] = [
|
|
291
|
+
{ name: "browserbase", docs: "https://docs.browserbase.com/reference/api/create-a-session", checked: "2026-09-29", title: "Browserbase", how: "session", host: "api.browserbase.com", secret: "BROWSERBASE_API_KEY",
|
|
292
|
+
session: { proxy: { on: true, country: true, state: true, city: true, own: true, needsCountry: true }, captcha: true, stealth: true, region: ["us-west-2", "us-east-1", "eu-central-1", "ap-southeast-1"], timeout: [60, 21600], keep: true, record: true, block: ["ads"], options: "body" },
|
|
293
|
+
what: "hosted Chromium with stealth; POST /v1/sessions (X-BB-API-Key) returns connectUrl" },
|
|
294
|
+
{ name: "steel", docs: "https://docs.steel.dev/overview/sessions-api/quickstart", checked: "2026-09-29", gaps: "session field names are from the official steel-node SDK source; the API reference page is script-only", title: "Steel", how: "session", host: "api.steel.dev", secret: "STEEL_API_KEY",
|
|
295
|
+
session: { proxy: { on: true, country: true, state: true, city: true, own: true }, captcha: true, stealth: true, timeout: [15, 86400], keep: true, block: ["ads"], options: "body" },
|
|
296
|
+
what: "hosted Chromium, open-source core; POST /v1/sessions (steel-api-key), then wss://connect.steel.dev?apiKey&sessionId" },
|
|
297
|
+
{ name: "hyperbrowser", docs: "https://hyperbrowser.ai/docs/api-reference/create-new-session.md", checked: "2026-09-29", title: "Hyperbrowser", how: "session", host: "api.hyperbrowser.ai", secret: "HYPERBROWSER_API_KEY",
|
|
298
|
+
session: { proxy: { on: true, country: true, state: true, city: true, own: true, stateOrCity: true }, captcha: true, stealth: true, region: ["us", "us-central", "us-west", "us-east", "asia-south", "europe-west"], timeout: [60, 43200], keep: true, record: true, block: ["ads", "trackers", "cookies"], options: "body" },
|
|
299
|
+
what: "hosted Chromium with built-in unblocking; a session returns its wsEndpoint" },
|
|
300
|
+
{ name: "browserless", docs: "https://docs.browserless.io/baas/connection-url-patterns.md", checked: "2026-09-29", title: "Browserless", how: "direct", host: "production-sfo.browserless.io", secret: "BROWSERLESS_TOKEN",
|
|
301
|
+
session: { proxy: { on: true, country: true, city: true, own: true }, captcha: true, stealth: true, region: ["sfo", "lon", "ams"], timeout: [1, 86400], block: ["ads"], options: "query" },
|
|
302
|
+
what: "hosted Chrome; wss://production-sfo.browserless.io?token=… (other regions by name)",
|
|
303
|
+
note: "SSPL-licensed: the free path is out for a paid service; the cloud is a plain vendor." },
|
|
304
|
+
{ name: "brightdata", docs: "https://docs.brightdata.com/products/scraping-browser/configuration.md", checked: "2026-09-29", lacks: ["tab"], gaps: "the limits are from a summary of the configuration page, not quoted verbatim", aliases: ["bright-data", "bright_data"], title: "Bright Data Scraping Browser", how: "direct", host: "brd.superproxy.io", secret: "BRIGHTDATA_BROWSER_AUTH",
|
|
305
|
+
session: { proxy: { always: true, country: true }, captcha: true, block: ["ads", "cookies"] },
|
|
306
|
+
secretFormat: "the zone credentials as `brd-customer-<id>-zone-<zone>:<password>` — the whole user:pass, which goes into the websocket URL",
|
|
307
|
+
what: "Chromium behind a residential proxy pool, port 9222 — the one worth paying for when a site refuses everything else" },
|
|
308
|
+
{ name: "cdp", docs: "https://chromedevtools.github.io/devtools-protocol/", checked: "2026-09-29", aliases: ["devtools"], title: "Any DevTools address", how: "direct", host: "(the address in the secret)", secret: "BROWSER_CDP_URL",
|
|
309
|
+
secretFormat: "the ws://, wss:// or http(s):// DevTools address, token included where the browser needs one",
|
|
310
|
+
what: "any browser that serves the DevTools protocol — a Chrome started with --remote-debugging-port, a self-hosted pool, a vendor not listed here" },
|
|
311
|
+
{ name: "zenrows", docs: "https://docs.zenrows.com/browser-sessions/get-started/playwright.md", checked: "2026-09-29", aliases: ["zen-rows", "zen_rows"], title: "ZenRows Scraping Browser", how: "direct", host: "browser.zenrows.com", secret: "ZENROWS_API_KEY",
|
|
312
|
+
session: { proxy: { always: true, country: true }, timeout: [60, 900], options: "query", refuses: ["user_agent", "device"] },
|
|
313
|
+
what: "hosted Chromium with residential IPs and fingerprinting; wss://browser.zenrows.com?apikey=…" },
|
|
314
|
+
];
|
|
315
|
+
|
|
316
|
+
/** The web's actions, in the order the docs list them. */
|
|
317
|
+
export const WEB_ACTIONS = ["search", "fetch", "browse", "crawl", "map", "extract", "answer", "monitor"] as const;
|
|
318
|
+
export type WebAction = (typeof WEB_ACTIONS)[number];
|
|
319
|
+
|
|
320
|
+
/** A provider for one of the actions that came after search, fetch and
|
|
321
|
+
* browse. Only what the runtime needs — the vault name of the key and the
|
|
322
|
+
* one host it may go to — plus the docs the adapter in the `web` tool was
|
|
323
|
+
* built from. None has been called live from here: no account holds these
|
|
324
|
+
* keys yet, so each is as good as its docs page. */
|
|
325
|
+
export interface ActionApi {
|
|
326
|
+
name: string;
|
|
327
|
+
title: string;
|
|
328
|
+
host: string;
|
|
329
|
+
secret: string;
|
|
330
|
+
docs: string;
|
|
331
|
+
checked: string;
|
|
332
|
+
gaps?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const ACTION_APIS: Record<"crawl" | "map" | "extract" | "answer" | "monitor", readonly ActionApi[]> = {
|
|
336
|
+
crawl: [
|
|
337
|
+
{ name: "firecrawl", title: "Firecrawl crawl", host: "api.firecrawl.dev", secret: "FIRECRAWL_API_KEY", docs: "https://docs.firecrawl.dev/api-reference/endpoint/crawl-post", checked: "2026-09-29" },
|
|
338
|
+
{ name: "tavily", title: "Tavily crawl", host: "api.tavily.com", secret: "TAVILY_API_KEY", docs: "https://docs.tavily.com/documentation/api-reference/endpoint/crawl", checked: "2026-09-29", gaps: "its results carry no title; the first heading is used" },
|
|
339
|
+
],
|
|
340
|
+
map: [
|
|
341
|
+
{ name: "firecrawl", title: "Firecrawl map", host: "api.firecrawl.dev", secret: "FIRECRAWL_API_KEY", docs: "https://docs.firecrawl.dev/api-reference/endpoint/map", checked: "2026-09-29" },
|
|
342
|
+
{ name: "tavily", title: "Tavily map", host: "api.tavily.com", secret: "TAVILY_API_KEY", docs: "https://docs.tavily.com/documentation/api-reference/endpoint/map", checked: "2026-09-29" },
|
|
343
|
+
],
|
|
344
|
+
extract: [
|
|
345
|
+
{ name: "firecrawl", title: "Firecrawl JSON extraction", host: "api.firecrawl.dev", secret: "FIRECRAWL_API_KEY", docs: "https://docs.firecrawl.dev/features/llm-extract", checked: "2026-09-29", gaps: "the feature page puts the result at data.json, the endpoint reference at data.answer; both are read" },
|
|
346
|
+
{ name: "zyte", title: "Zyte custom attributes", host: "api.zyte.com", secret: "ZYTE_API_KEY_BASIC", docs: "https://docs.zyte.com/zyte-api/usage/extract/custom-attributes.html", checked: "2026-09-29", gaps: "no free-text prompt: a prompt becomes one attribute's description" },
|
|
347
|
+
{ name: "hyperbrowser", title: "Hyperbrowser extract", host: "api.hyperbrowser.ai", secret: "HYPERBROWSER_API_KEY", docs: "https://hyperbrowser.ai/docs/web-scraping/extract", checked: "2026-09-29", gaps: "whether schema or prompt is required is not stated" },
|
|
348
|
+
],
|
|
349
|
+
answer: [
|
|
350
|
+
{ name: "exa", title: "Exa answer", host: "api.exa.ai", secret: "EXA_API_KEY", docs: "https://exa.ai/docs/reference/answer", checked: "2026-09-29" },
|
|
351
|
+
{ name: "linkup", title: "Linkup sourced answer", host: "api.linkup.so", secret: "LINKUP_API_KEY", docs: "https://docs.linkup.so/pages/documentation/api-reference/endpoint/post-search", checked: "2026-09-29" },
|
|
352
|
+
{ name: "tavily", title: "Tavily answer", host: "api.tavily.com", secret: "TAVILY_API_KEY", docs: "https://docs.tavily.com/documentation/api-reference/endpoint/search", checked: "2026-09-29", gaps: "the sources are the results it searched, not per-sentence citations" },
|
|
353
|
+
{ name: "parallel", title: "Parallel Responses", host: "api.parallel.ai", secret: "PARALLEL_API_KEY", docs: "https://docs.parallel.ai/responses-api/responses-quickstart.md", checked: "2026-09-29", gaps: "the citation nesting is not pinned down; both spellings are read" },
|
|
354
|
+
{ name: "perplexity", title: "Perplexity Agent API", host: "api.perplexity.ai", secret: "PERPLEXITY_API_KEY", docs: "https://docs.perplexity.ai/api-reference/agent-post", checked: "2026-09-29", gaps: "Sonar chat completions ended 27 Sep 2026; whether preset fast searches unasked is not stated, so web_search is asked for" },
|
|
355
|
+
{ name: "you", title: "You.com answer", host: "api.you.com", secret: "YOU_API_KEY", docs: "https://you.com/docs/guides/answer/quickstart.md", checked: "2026-09-29", gaps: "the answer's place (data.answer or answer) and the citation field names are not pinned down; both are read" },
|
|
356
|
+
],
|
|
357
|
+
monitor: [
|
|
358
|
+
{ name: "parallel", title: "Parallel Monitor", host: "api.parallel.ai", secret: "PARALLEL_API_KEY", docs: "https://docs.parallel.ai/api-reference/monitor/create-monitor", checked: "2026-09-29", gaps: "an event's output has no documented title or url; its text and citations are read" },
|
|
359
|
+
{ name: "firecrawl", title: "Firecrawl change tracking", host: "api.firecrawl.dev", secret: "FIRECRAWL_API_KEY", docs: "https://docs.firecrawl.dev/features/change-tracking", checked: "2026-09-29" },
|
|
360
|
+
],
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export function findActionApi(action: keyof typeof ACTION_APIS, name: string): ActionApi | undefined {
|
|
364
|
+
const key = name.trim().toLowerCase();
|
|
365
|
+
return ACTION_APIS[action].find((a) => a.name === key);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function findBrowserApi(name: string): BrowserApi | undefined {
|
|
369
|
+
const key = name.trim().toLowerCase();
|
|
370
|
+
return BROWSER_APIS.find((a) => a.name === key || a.aliases?.includes(key));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function findFetchApi(name: string): FetchApi | undefined {
|
|
374
|
+
const key = name.trim().toLowerCase();
|
|
375
|
+
return FETCH_APIS.find((a) => a.name === key || a.aliases?.includes(key));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function findSearchApi(name: string): SearchApi | undefined {
|
|
379
|
+
const key = name.trim().toLowerCase();
|
|
380
|
+
return SEARCH_APIS.find((a) => a.name === key || a.aliases?.includes(key));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Whose index answers, for the ones that will answer at all. Checked against
|
|
384
|
+
* each vendor's own documentation on 2026-09-16. Worth stating out loud
|
|
385
|
+
* because it is the whole reason to prefer a provider's search over ours:
|
|
386
|
+
* you are buying an index we cannot crawl, not a faster endpoint. */
|
|
387
|
+
export const SEARCH_INDEX: Record<string, string> = {
|
|
388
|
+
anthropic: "Brave",
|
|
389
|
+
zai: "Zhipu's own (China-weighted)",
|
|
390
|
+
openrouter: "native where the model supports it, Exa otherwise",
|
|
391
|
+
openai: "Bing, plus OpenAI's own OAI-SearchBot crawl",
|
|
392
|
+
kimi: "Moonshot's own (China-weighted)",
|
|
393
|
+
};
|
|
394
|
+
|
|
63
395
|
export const PROVIDERS: readonly ProviderPreset[] = [
|
|
64
396
|
// ------------------------------------------------ Anthropic-shaped, direct
|
|
65
397
|
{ name: "anthropic", title: "Anthropic", format: "anthropic", baseUrl: "https://api.anthropic.com", auth: "x-api-key", verified: true,
|
|
66
|
-
note: "Models newer than Opus 4.6 reject top_k with a 400; the runtime never sends it unless a params: block does." },
|
|
398
|
+
note: "Models newer than Opus 4.6 reject top_k with a 400; the runtime never sends it unless a params: block does." , search: "anthropic" },
|
|
67
399
|
{ name: "openrouter", title: "OpenRouter", format: "anthropic", baseUrl: "https://openrouter.ai/api", auth: "bearer", verified: true,
|
|
68
|
-
note: "Hundreds of models behind one key. Its own docs disagree on how well non-Anthropic models hold a tool loop on this endpoint — probe the model you mean to use." },
|
|
400
|
+
note: "Hundreds of models behind one key. Its own docs disagree on how well non-Anthropic models hold a tool loop on this endpoint — probe the model you mean to use." , search: "plugin" },
|
|
69
401
|
{ name: "deepseek", title: "DeepSeek", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic", auth: "x-api-key",
|
|
70
402
|
note: "Ignores top_k, cache_control and thinking budgets; Claude model names are remapped to DeepSeek's." },
|
|
71
403
|
{ name: "kimi", title: "Moonshot Kimi", format: "anthropic", baseUrl: "https://api.moonshot.ai/anthropic", auth: "bearer",
|
|
72
|
-
note: "Own model ids only. Known bug (2026-09): K3 reuses one tool_use id across separate calls, which breaks a tool loop." },
|
|
404
|
+
note: "Own model ids only. Known bug (2026-09): K3 reuses one tool_use id across separate calls, which breaks a tool loop." , search: "builtin_fn" },
|
|
73
405
|
{ name: "moonshot", title: "Moonshot Kimi", format: "anthropic", baseUrl: "https://api.moonshot.ai/anthropic", auth: "bearer",
|
|
74
|
-
note: "Same endpoint as kimi." },
|
|
75
|
-
{ name: "zai", title: "z.ai (GLM)", format: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", auth: "bearer" },
|
|
406
|
+
note: "Same endpoint as kimi." , search: "builtin_fn" },
|
|
407
|
+
{ name: "zai", title: "z.ai (GLM)", format: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", auth: "bearer" , search: "anthropic" },
|
|
76
408
|
{ name: "minimax", title: "MiniMax", format: "anthropic", baseUrl: "https://api.minimax.io/anthropic", auth: "bearer" },
|
|
77
409
|
{ name: "qwen", title: "Alibaba Qwen (Model Studio)", format: "anthropic", auth: "x-api-key",
|
|
78
410
|
note: "base_url depends on the plan: pay-as-you-go https://<workspace>.<region>.maas.aliyuncs.com/apps/anthropic; Coding Plan https://coding-intl.dashscope.aliyuncs.com/apps/anthropic. Own model ids (qwen3.7-max …), no remap; no reasoning_effort, use thinking." },
|
|
@@ -96,7 +428,7 @@ export const PROVIDERS: readonly ProviderPreset[] = [
|
|
|
96
428
|
// ------------------------------------------- Chat-Completions, translated
|
|
97
429
|
{ name: "openai", title: "OpenAI", format: "openai", baseUrl: "https://api.openai.com/v1", auth: "bearer", verified: true,
|
|
98
430
|
maxTokensParam: "max_completion_tokens", reasoningEffort: true,
|
|
99
|
-
note: "Reached over Chat Completions, which OpenAI keeps supporting; the Responses API is not spoken here, so a reasoning model's reasoning does not carry across tool calls." },
|
|
431
|
+
note: "Reached over Chat Completions, which OpenAI keeps supporting; the Responses API is not spoken here, so a reasoning model's reasoning does not carry across tool calls." , search: "openai" },
|
|
100
432
|
{ name: "gemini", title: "Google Gemini", format: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", auth: "bearer",
|
|
101
433
|
note: "Google's OpenAI-compatible route. Unknown parameters are ignored silently; reasoning cannot be switched off on the newest models." },
|
|
102
434
|
{ name: "xai", title: "xAI Grok", format: "openai", baseUrl: "https://api.x.ai/v1", auth: "bearer",
|
|
@@ -145,3 +477,1033 @@ export function providerPreset(name: unknown): ProviderPreset | null {
|
|
|
145
477
|
export function looksOpenAiShaped(baseUrl: string): boolean {
|
|
146
478
|
return /\/v1\/?$|\/chat\/completions|api\.openai\.com|\/openai\b/i.test(baseUrl);
|
|
147
479
|
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
/** What `web.search: <name>` in an agent's frontmatter resolves to.
|
|
483
|
+
*
|
|
484
|
+
* Unset means ours: the account's own search engine, free, on the run
|
|
485
|
+
* record. Naming a provider buys that provider's index instead — which is
|
|
486
|
+
* the only reason to do it, and the reason the failure modes below are
|
|
487
|
+
* errors rather than a quiet fallback. A desk that silently searched
|
|
488
|
+
* nothing for a month is worse than one that refused to deploy.
|
|
489
|
+
*/
|
|
490
|
+
export interface SearchChoice {
|
|
491
|
+
/** null when ours answers. */
|
|
492
|
+
provider: string | null;
|
|
493
|
+
shape?: SearchShape;
|
|
494
|
+
/** Whose index answers, for the run trace and the help page. */
|
|
495
|
+
index?: string;
|
|
496
|
+
/** For a direct API: the vault name of the customer's key, and the one
|
|
497
|
+
* host the egress proxy may fill it in for. */
|
|
498
|
+
secret?: string;
|
|
499
|
+
secretOptional?: boolean;
|
|
500
|
+
host?: string;
|
|
501
|
+
/** Set when the name cannot work; `check` prints it and refuses. */
|
|
502
|
+
error?: string;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** `web.search: exa`, or the long form with the customer's own vault name:
|
|
506
|
+
*
|
|
507
|
+
* web:
|
|
508
|
+
* search:
|
|
509
|
+
* name: exa
|
|
510
|
+
* key: ${MY_EXA_KEY}
|
|
511
|
+
*
|
|
512
|
+
* The same two spellings `provider:` takes. `key` is a reference, never a
|
|
513
|
+
* value — a credential written into a markdown file is refused here, the
|
|
514
|
+
* way it is refused everywhere else in foldrun. */
|
|
515
|
+
/**
|
|
516
|
+
* `web.browse:` settings — how the browser presents itself, not what one call
|
|
517
|
+
* does.
|
|
518
|
+
*
|
|
519
|
+
* web:
|
|
520
|
+
* browse:
|
|
521
|
+
* engine: firefox
|
|
522
|
+
* user_agent: "Mozilla/5.0 …"
|
|
523
|
+
*
|
|
524
|
+
* The engine and the user agent belong in the file, not in every call. They
|
|
525
|
+
* travel together: a Cloudflare clearance cookie is bound to the user agent
|
|
526
|
+
* that earned it, so a skill repeating the UA in each call is one edit away
|
|
527
|
+
* from a session that silently stops working (Medium, 2026-09-17).
|
|
528
|
+
*
|
|
529
|
+
* `via:` (or `name:`) names a remote browser vendor, which is what this key
|
|
530
|
+
* meant when it only took a string; that spelling still works. Anything a
|
|
531
|
+
* single call decides — mode, actions, wait_for, block — stays in the call.
|
|
532
|
+
*/
|
|
533
|
+
export interface BrowseSettings {
|
|
534
|
+
engine?: "chrome" | "chromium" | "firefox" | "webkit" | "lightpanda" | "obscura";
|
|
535
|
+
user_agent?: string;
|
|
536
|
+
device?: string;
|
|
537
|
+
locale?: string;
|
|
538
|
+
timezone?: string;
|
|
539
|
+
/** The NAME of a vault secret holding this site's sign-in cookies, never
|
|
540
|
+
* the cookies themselves. It sits here beside `user_agent` because the two
|
|
541
|
+
* are one thing: a Cloudflare clearance cookie is bound to the user agent
|
|
542
|
+
* that earned it, and a file that carries one without the other is a
|
|
543
|
+
* session waiting to stop working. */
|
|
544
|
+
cookies?: string;
|
|
545
|
+
cookie_domain?: string;
|
|
546
|
+
/** The NAME of a vault secret holding this site's signed-in Web Storage and
|
|
547
|
+
* IndexedDB, as JSON: `{ localStorage, sessionStorage, indexedDB }`. The
|
|
548
|
+
* sibling of `cookies:` for the sites that keep their login in storage
|
|
549
|
+
* rather than a cookie — Firebase writes a record to IndexedDB, MSAL can
|
|
550
|
+
* keep tokens in sessionStorage, and a cookie jar alone opens those pages
|
|
551
|
+
* signed out. Seeded before the page's own scripts run, never read back. */
|
|
552
|
+
storage?: string;
|
|
553
|
+
storage_origin?: string;
|
|
554
|
+
/** Named bundles of the same settings plus the per-call ones that travel
|
|
555
|
+
* with an identity — proxy, headers, geolocation, permissions,
|
|
556
|
+
* color_scheme, block — chosen on a call as `identity=<name>`. "As an
|
|
557
|
+
* Australian phone" is six settings; a name says it once. Each value is a
|
|
558
|
+
* map of text (or JSON for headers), validated here the way the block is. */
|
|
559
|
+
identities?: Record<string, Record<string, string>>;
|
|
560
|
+
/** false runs the full browser with a window, on a virtual screen where
|
|
561
|
+
* the machine has none. Unset or true is the usual no-window browser. */
|
|
562
|
+
headless?: boolean;
|
|
563
|
+
/** Which build of the engine to run: a Chrome release channel
|
|
564
|
+
* (`stable`/`beta`/`dev`) or a label naming a binary installed in the
|
|
565
|
+
* image (`/opt/browser/<engine>/<version>/`). Unset is the pinned
|
|
566
|
+
* default. Resolved in the tool, which falls back to the default and
|
|
567
|
+
* says so when the named build is not installed. */
|
|
568
|
+
version?: string;
|
|
569
|
+
/** true keeps the browser and its open page alive between this agent's
|
|
570
|
+
* calls in a step, so a multi-step form can be driven one step per call.
|
|
571
|
+
* Chromium-only (chrome/chromium). Unset: each call is a fresh page, with
|
|
572
|
+
* only cookies carried by a session. */
|
|
573
|
+
live?: boolean;
|
|
574
|
+
/** The domains this agent's browser may reach at all — pages, scripts,
|
|
575
|
+
* fetches, sockets. A lock, not a default: a call may narrow it and never
|
|
576
|
+
* widen it, so no page's text can talk the model past it. */
|
|
577
|
+
allowed_domains?: string[];
|
|
578
|
+
/** Actions this agent may never take (eval, download, upload, …), checked
|
|
579
|
+
* before a browser opens. Names from WEB_BROWSE_ACTIONS, plus js. */
|
|
580
|
+
deny?: string[];
|
|
581
|
+
/** true marks the page's words in every reply with a nonce the page cannot
|
|
582
|
+
* know, so text written to look like instructions stays the page's. */
|
|
583
|
+
boundaries?: boolean;
|
|
584
|
+
/** Workspace paths of scripts run in every page before its own. */
|
|
585
|
+
init?: string[];
|
|
586
|
+
/** Workspace paths of unpacked Chrome extensions to load (Chromium, in the step). */
|
|
587
|
+
extensions?: string[];
|
|
588
|
+
/** true gives pages WebGPU, on a software GPU where there is none. */
|
|
589
|
+
webgpu?: boolean;
|
|
590
|
+
/** true opens pages whose certificate does not check out (staging, self-signed). */
|
|
591
|
+
ignore_https_errors?: boolean;
|
|
592
|
+
/** The NAME of a vault secret whose value encrypts state= logins at rest. */
|
|
593
|
+
state_key?: string;
|
|
594
|
+
/** true records every call, so the run page always has the recording to play. */
|
|
595
|
+
video?: boolean;
|
|
596
|
+
/** false stops the tool streaming its page to the run page while the step
|
|
597
|
+
* runs. Unset is on wherever the platform can take the frames. */
|
|
598
|
+
live_view?: boolean;
|
|
599
|
+
/** What the vendor's own session is asked for (only with a vendor).
|
|
600
|
+
* Written `vendor_session:`; `session:` is the deprecated spelling. */
|
|
601
|
+
vendor_session?: BrowseSession;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Every action web browse takes, which is what `deny:` may name. The
|
|
605
|
+
* gallery test holds this equal to the tool's own list, so a new action
|
|
606
|
+
* cannot be undeniable, and a misspelt denial — which would deny nothing —
|
|
607
|
+
* is refused at check time instead. */
|
|
608
|
+
export const WEB_BROWSE_ACTIONS = [
|
|
609
|
+
"click", "dblclick", "rightclick", "hover", "check", "uncheck", "drag", "fill", "type", "press", "select",
|
|
610
|
+
"wait", "goto", "screenshot", "pdf", "scroll", "paste", "upload", "download", "extract", "frame", "tab",
|
|
611
|
+
"back", "forward", "reload", "focus", "clear", "highlight", "keyboard", "keydown", "keyup", "mouse", "wheel",
|
|
612
|
+
"tap", "swipe", "clipboard", "eval", "get", "expect", "if", "mock", "unmock", "offline", "cookie",
|
|
613
|
+
"localstorage", "sessionstorage", "viewport", "dialog", "solve",
|
|
614
|
+
"pushstate", "insert", "webmcp", "login",
|
|
615
|
+
] as const;
|
|
616
|
+
|
|
617
|
+
// What a person writes, and what Playwright calls it. "chromium" and "webkit"
|
|
618
|
+
// are engine names; "chrome" and "safari" are what everyone else calls those
|
|
619
|
+
// browsers, and a file should read the way the room talks. Both spellings are
|
|
620
|
+
// accepted forever: agents written before this keep working, and the engine
|
|
621
|
+
// names remain the truth underneath (chrome here is the open-source Chromium
|
|
622
|
+
// build, not the branded Chrome; safari is WebKit, Safari's engine).
|
|
623
|
+
const BROWSE_ENGINE_ALIASES: Record<string, "chrome" | "chromium" | "firefox" | "webkit" | "lightpanda" | "obscura"> = {
|
|
624
|
+
// `chrome` is the branded Google Chrome (Playwright channel "chrome"), where
|
|
625
|
+
// the image has it; `chromium` is the open-source build. Same engine, a
|
|
626
|
+
// truer user-agent and codecs. Both still fall back to chromium if Chrome
|
|
627
|
+
// is not installed (a laptop without it, or an arch Google does not ship).
|
|
628
|
+
chrome: "chrome",
|
|
629
|
+
chromium: "chromium",
|
|
630
|
+
firefox: "firefox",
|
|
631
|
+
safari: "webkit",
|
|
632
|
+
webkit: "webkit",
|
|
633
|
+
// A browser that runs the JavaScript and never draws: a fraction of
|
|
634
|
+
// Chromium's memory, and no screenshots.
|
|
635
|
+
lightpanda: "lightpanda",
|
|
636
|
+
// A headless browser in Rust around V8 that does draw (screenshots, a
|
|
637
|
+
// raster PDF), light like Lightpanda; no request interception.
|
|
638
|
+
obscura: "obscura",
|
|
639
|
+
};
|
|
640
|
+
// Six engines, chromium the default. The list a refusal prints, so a person
|
|
641
|
+
// sees every spelling that works (webkit is safari's engine name and is
|
|
642
|
+
// accepted too, as an alias, not a seventh browser).
|
|
643
|
+
export const BROWSE_ENGINES = ["chromium", "chrome", "firefox", "safari", "lightpanda", "obscura"] as const;
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* What each engine cannot do, and the words a refusal uses. ONE table: the
|
|
647
|
+
* tool (web/browse.mjs in the platform gallery) carries a copy that refuses a
|
|
648
|
+
* call, and `foldrun check` reads this one to refuse a `web.browse:` block
|
|
649
|
+
* that pairs an engine with something it cannot do — so the mistake is found
|
|
650
|
+
* at check time, not at 3am. The gallery test holds the tool's copy equal to
|
|
651
|
+
* this one, so neither can learn an option the other does not.
|
|
652
|
+
*
|
|
653
|
+
* An entry is what the tool prints for the option: a call argument (video,
|
|
654
|
+
* trace, block…), `mode=<mode>`, or `headless: false`. A refusal reads
|
|
655
|
+
* `<entry> <why>`. chromium and chrome can do all of it, so they have no row.
|
|
656
|
+
*/
|
|
657
|
+
export const BROWSE_ENGINE_LIMITS: Record<string, { why: string; cannot: readonly string[] }> = {
|
|
658
|
+
// Lightpanda never draws: no pixels, so nothing built on them.
|
|
659
|
+
lightpanda: {
|
|
660
|
+
why: "needs a browser that renders; lightpanda reads and drives pages without drawing them — use engine chrome",
|
|
661
|
+
cannot: [
|
|
662
|
+
"mode=screenshot", "mode=pdf", "mode=vitals", "mode=webmcp", "pdf", "webmcp", "video", "trace", "har", "device", "live", "session",
|
|
663
|
+
"webgpu", "extensions", "annotate", "visual", "cpu_profile", "proxy", "headless: false",
|
|
664
|
+
],
|
|
665
|
+
},
|
|
666
|
+
// Obscura draws — screenshots, a raster pdf — but cannot intercept a
|
|
667
|
+
// request (a route hangs its page loads), record, or keep a profile — so nothing built on those, which
|
|
668
|
+
// includes the allowed_domains lock: a lock that did nothing would be
|
|
669
|
+
// worse than a refusal.
|
|
670
|
+
obscura: {
|
|
671
|
+
why: "is not something obscura can do (it has no request interception, recording or profiles) — use engine chrome",
|
|
672
|
+
cannot: [
|
|
673
|
+
"mode=webmcp", "webmcp", "video", "trace", "har", "device", "live", "session", "webgpu", "extensions", "cpu_profile",
|
|
674
|
+
"block", "routes", "mock", "unmock", "proxy", "captcha", "allowed_domains", "headless: false",
|
|
675
|
+
],
|
|
676
|
+
},
|
|
677
|
+
firefox: { why: "is a Chromium feature — use engine chrome or chromium", cannot: ["mode=pdf", "mode=webmcp", "pdf", "webmcp", "live", "webgpu", "extensions", "cpu_profile"] },
|
|
678
|
+
webkit: { why: "is a Chromium feature — use engine chrome or chromium", cannot: ["mode=pdf", "mode=webmcp", "pdf", "webmcp", "live", "webgpu", "extensions", "cpu_profile"] },
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
/** The refusal for one option on one engine, or null when the engine can. */
|
|
682
|
+
export function browseEngineRefusal(engine: string, what: string): string | null {
|
|
683
|
+
const row = BROWSE_ENGINE_LIMITS[engine];
|
|
684
|
+
return row && row.cannot.includes(what) ? `${what} ${row.why}` : null;
|
|
685
|
+
}
|
|
686
|
+
const BROWSE_SETTING_KEYS = [
|
|
687
|
+
"engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
|
|
688
|
+
"allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
|
|
689
|
+
"video", "live_view", "vendor_session",
|
|
690
|
+
// Deprecated spelling of vendor_session (read, and warned about).
|
|
691
|
+
"session",
|
|
692
|
+
] as const;
|
|
693
|
+
const BROWSE_DOMAIN = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
|
|
694
|
+
/** A list setting: YAML's list, or one comma-separated line. */
|
|
695
|
+
function browseList(v: unknown): string[] | null {
|
|
696
|
+
if (Array.isArray(v)) return v.every((x) => typeof x === "string") ? v.map((x) => x.trim()).filter(Boolean) : null;
|
|
697
|
+
if (typeof v === "string") return v.split(",").map((x) => x.trim()).filter(Boolean);
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
// What one identity may carry: the block's own settings, and the call
|
|
701
|
+
// arguments that describe who the browser is rather than what one call does.
|
|
702
|
+
const IDENTITY_KEYS = ["engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "proxy", "headers", "geolocation", "permissions", "color_scheme", "block"] as const;
|
|
703
|
+
const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
704
|
+
|
|
705
|
+
/** One named identity, checked the way the block is: text values, an
|
|
706
|
+
* engine that exists, secrets by NAME, a cookie with its domain. */
|
|
707
|
+
function readIdentity(name: string, raw: unknown): { identity?: Record<string, string>; error?: string } {
|
|
708
|
+
const where = `web.browse.identities.${name}`;
|
|
709
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) return { error: `web.browse.identities: "${name}" is not a plain name (letters, digits, dot, dash, underscore).` };
|
|
710
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: `${where} must be a block of settings, like { device: "Pixel 7", locale: en-AU }.` };
|
|
711
|
+
const out: Record<string, string> = {};
|
|
712
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
713
|
+
if (!(IDENTITY_KEYS as readonly string[]).includes(k)) return { error: `${where}.${k} is not an identity setting — they are ${IDENTITY_KEYS.join(", ")}.` };
|
|
714
|
+
if (v === undefined || v === null || v === "") continue;
|
|
715
|
+
if (k === "headers") {
|
|
716
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return { error: `${where}.headers must be a map of header to value.` };
|
|
717
|
+
out.headers = JSON.stringify(v);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (typeof v !== "string") return { error: `${where}.${k} must be text, not ${Array.isArray(v) ? "a list" : typeof v}.` };
|
|
721
|
+
if (k === "engine") {
|
|
722
|
+
const canonical = BROWSE_ENGINE_ALIASES[v.toLowerCase()];
|
|
723
|
+
if (!canonical) return { error: `${where}.engine: ${v} — the browsers are ${BROWSE_ENGINES.join(", ")}.` };
|
|
724
|
+
out.engine = canonical;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if ((k === "cookies" || k === "storage" || k === "proxy") && !SECRET_NAME.test(v)) {
|
|
728
|
+
return { error: `${where}.${k} must be the NAME of a vault secret (CAPITALS), never the value — store it with \`foldrun secrets set NAME\`.` };
|
|
729
|
+
}
|
|
730
|
+
out[k] = v;
|
|
731
|
+
}
|
|
732
|
+
if (out.cookies && !out.cookie_domain) return { error: `${where}.cookies needs cookie_domain beside it — write \`cookie_domain: .example.com\`.` };
|
|
733
|
+
if (out.storage && !out.storage_origin) return { error: `${where}.storage needs storage_origin beside it — write \`storage_origin: https://www.example.com\`.` };
|
|
734
|
+
return { identity: out };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** The settings in a `web.browse:` block, and the vendor part with them
|
|
738
|
+
* removed — so one key carries both without either learning about the
|
|
739
|
+
* other. A string, or a block with no settings, gives no settings at all. */
|
|
740
|
+
const SESSION_KEYS = ["proxy", "captcha", "stealth", "region", "timeout", "keep", "record", "block", "options"] as const;
|
|
741
|
+
const BLOCKABLE = ["ads", "trackers", "cookies"] as const;
|
|
742
|
+
|
|
743
|
+
/** A duration as a person writes one — 90s, 30m, 2h, 1d, or a bare number
|
|
744
|
+
* of seconds — in seconds; null when it is none of those. */
|
|
745
|
+
function seconds(v: unknown): number | null {
|
|
746
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) return Math.round(v);
|
|
747
|
+
const m = typeof v === "string" ? /^\s*(\d+(?:\.\d+)?)\s*(s|m|h|d)?\s*$/i.exec(v) : null;
|
|
748
|
+
if (!m) return null;
|
|
749
|
+
return Math.round(Number(m[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[(m[2] ?? "s").toLowerCase() as "s"]);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** The shape of `web.browse.vendor_session:`, before anyone knows the vendor. What
|
|
753
|
+
* the vendor can do is browseSessionProblems' question. */
|
|
754
|
+
export function readBrowseSession(raw: unknown, key: "vendor_session" | "session" = "vendor_session"): { session?: BrowseSession; error?: string } {
|
|
755
|
+
const at = `web.browse.${key}`;
|
|
756
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
757
|
+
return { error: `${at} is a block — what the vendor's own session is asked for: proxy, captcha, stealth, region, timeout, keep, record, block, options.` };
|
|
758
|
+
}
|
|
759
|
+
const out: BrowseSession = {};
|
|
760
|
+
const bool = (k: string, v: unknown) => (v === true || v === "true" ? true : v === false || v === "false" ? false : undefined);
|
|
761
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
762
|
+
if (!(SESSION_KEYS as readonly string[]).includes(k)) return { error: `${at}.${k}: is not a session setting. They are ${SESSION_KEYS.join(", ")}.` };
|
|
763
|
+
if (v === undefined || v === null || v === "") continue;
|
|
764
|
+
if (k === "captcha" || k === "stealth" || k === "record") {
|
|
765
|
+
const b = bool(k, v);
|
|
766
|
+
if (b === undefined) return { error: `${at}.${k} is true or false, not ${JSON.stringify(v)}.` };
|
|
767
|
+
out[k] = b;
|
|
768
|
+
} else if (k === "proxy") {
|
|
769
|
+
const b = bool(k, v);
|
|
770
|
+
if (b !== undefined) { out.proxy = b; continue; }
|
|
771
|
+
if (typeof v !== "object" || Array.isArray(v)) return { error: `${at}.proxy is true, false, or a block: country, state, city — or own: a secret holding your proxy's URL.` };
|
|
772
|
+
const p: { country?: string; state?: string; city?: string; own?: string } = {};
|
|
773
|
+
for (const [pk, pv] of Object.entries(v as Record<string, unknown>)) {
|
|
774
|
+
if (pk === "country" || pk === "state") {
|
|
775
|
+
if (typeof pv !== "string" || !/^[A-Za-z]{2}$/.test(pv.trim())) return { error: `${at}.proxy.${pk} is a two-letter code, like ${pk === "country" ? "AU" : "CA"} — not ${JSON.stringify(pv)}.` };
|
|
776
|
+
p[pk] = pv.trim().toUpperCase();
|
|
777
|
+
} else if (pk === "city") {
|
|
778
|
+
if (typeof pv !== "string" || !pv.trim()) return { error: `${at}.proxy.city is a city's name.` };
|
|
779
|
+
p.city = pv.trim();
|
|
780
|
+
} else if (pk === "own") {
|
|
781
|
+
if (typeof pv !== "string" || !SECRET_NAME.test(pv.trim())) return { error: `${at}.proxy.own names a secret holding your proxy's URL (http://user:pass@host:port) — the NAME, never the URL.` };
|
|
782
|
+
p.own = pv.trim();
|
|
783
|
+
} else {
|
|
784
|
+
return { error: `${at}.proxy.${pk}: is not a proxy setting. They are country, state, city, own.` };
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (p.own && (p.country || p.state || p.city)) return { error: `${at}.proxy: own is your proxy, so its location is yours to set there — drop country, state and city, or own.` };
|
|
788
|
+
if (p.state && p.country && p.country !== "US") return { error: `${at}.proxy.state is for the US only.` };
|
|
789
|
+
out.proxy = p;
|
|
790
|
+
} else if (k === "region" || k === "keep") {
|
|
791
|
+
if (typeof v !== "string" || !v.trim()) return { error: `${at}.${k} is a name.` };
|
|
792
|
+
if (k === "keep" && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(v.trim())) return { error: `${at}.keep is a short name in lower case, like client-portal — the saved login it keeps.` };
|
|
793
|
+
out[k] = v.trim();
|
|
794
|
+
} else if (k === "timeout") {
|
|
795
|
+
const n = seconds(v);
|
|
796
|
+
if (n === null) return { error: `${at}.timeout is a duration, like 90s, 30m or 2h — not ${JSON.stringify(v)}.` };
|
|
797
|
+
out.timeout = n;
|
|
798
|
+
} else if (k === "block") {
|
|
799
|
+
const list = browseList(v);
|
|
800
|
+
const bad = list?.find((x) => !(BLOCKABLE as readonly string[]).includes(x));
|
|
801
|
+
if (!list || bad !== undefined) return { error: `${at}.block is a list of ${BLOCKABLE.join(", ")}${bad ? ` — not ${bad}` : ""}.` };
|
|
802
|
+
out.block = [...new Set(list)] as BrowseSession["block"];
|
|
803
|
+
} else {
|
|
804
|
+
if (typeof v !== "object" || Array.isArray(v)) return { error: `${at}.options is a block, passed to the vendor as written.` };
|
|
805
|
+
out.options = v as Record<string, unknown>;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return { session: out };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Everything wrong with a session block for this vendor: a setting the
|
|
813
|
+
* vendor has no option for (naming the vendors that do), a value outside its
|
|
814
|
+
* range, and the browse settings its browser will not take. The rule the
|
|
815
|
+
* actions follow — the file is fixed, nothing is quietly dropped.
|
|
816
|
+
*/
|
|
817
|
+
export function browseSessionProblems(settings: BrowseSettings, vendor: string | null): string[] {
|
|
818
|
+
const s = settings.vendor_session;
|
|
819
|
+
const out: string[] = [];
|
|
820
|
+
const api = vendor ? findBrowserApi(vendor) : undefined;
|
|
821
|
+
const sup = api?.session ?? {};
|
|
822
|
+
const who = (pred: (x: SessionSupport) => boolean | undefined) =>
|
|
823
|
+
BROWSER_APIS.filter((b) => b.session && pred(b.session)).map((b) => b.name).join(", ") || "none";
|
|
824
|
+
const at = "web.browse.vendor_session";
|
|
825
|
+
for (const key of api?.session?.refuses ?? []) {
|
|
826
|
+
if ((settings as Record<string, unknown>)[key] !== undefined) {
|
|
827
|
+
out.push(`web.browse.${key}: ${api!.title} does not let a session change it (its docs) — drop it, or render elsewhere.`);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (!s) return out;
|
|
831
|
+
if (!api) {
|
|
832
|
+
out.push(`${at}: is what a vendor's own session is asked for, and this browser is the account's own — name one with via:, or drop vendor_session:.`);
|
|
833
|
+
return out;
|
|
834
|
+
}
|
|
835
|
+
const label = api.title;
|
|
836
|
+
const need = (key: string, ok: boolean | undefined, pred: (x: SessionSupport) => boolean | undefined) => {
|
|
837
|
+
if (!ok) out.push(`${at}.${key}: ${label} has no such option. These do: ${who(pred)}.`);
|
|
838
|
+
};
|
|
839
|
+
if (s.proxy !== undefined) {
|
|
840
|
+
const p = sup.proxy;
|
|
841
|
+
if (s.proxy === false && p?.always) out.push(`${at}.proxy: ${label} always goes through its own proxy network — false cannot be kept.`);
|
|
842
|
+
else if (s.proxy === true) need("proxy", p?.on || p?.always, (x) => x.proxy?.on || x.proxy?.always);
|
|
843
|
+
else if (typeof s.proxy === "object") {
|
|
844
|
+
for (const part of ["country", "state", "city", "own"] as const) {
|
|
845
|
+
if (s.proxy[part] !== undefined) need(`proxy.${part}`, p?.[part], (x) => x.proxy?.[part]);
|
|
846
|
+
}
|
|
847
|
+
if (p?.needsCountry && !s.proxy.own && !s.proxy.country) out.push(`${at}.proxy: ${label} needs a country for a proxy location (its docs) — add country:.`);
|
|
848
|
+
if (p?.stateOrCity && s.proxy.state && s.proxy.city) out.push(`${at}.proxy: ${label} takes a state or a city, not both (its docs).`);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (s.captcha !== undefined) need("captcha", sup.captcha, (x) => x.captcha);
|
|
852
|
+
if (s.stealth !== undefined) need("stealth", sup.stealth, (x) => x.stealth);
|
|
853
|
+
if (s.record !== undefined) need("record", sup.record, (x) => x.record);
|
|
854
|
+
if (s.keep !== undefined) {
|
|
855
|
+
need("keep", sup.keep, (x) => x.keep);
|
|
856
|
+
// A saved login lives in the vendor's own browser context, which is
|
|
857
|
+
// used as it is: a context made to measure would not be the saved one.
|
|
858
|
+
for (const key of ["user_agent", "device", "locale", "timezone", "identities"] as const) {
|
|
859
|
+
if ((settings as Record<string, unknown>)[key] !== undefined) {
|
|
860
|
+
out.push(`web.browse.${key}: with vendor_session.keep the vendor's saved browser is used as it stands, so ${key} cannot be applied — drop one of them.`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (s.region !== undefined) {
|
|
865
|
+
if (!sup.region) need("region", false, (x) => Boolean(x.region));
|
|
866
|
+
else if (!sup.region.includes(s.region)) out.push(`${at}.region: ${label}'s regions are ${sup.region.join(", ")} — not ${s.region}.`);
|
|
867
|
+
}
|
|
868
|
+
if (s.timeout !== undefined) {
|
|
869
|
+
if (!sup.timeout) need("timeout", false, (x) => Boolean(x.timeout));
|
|
870
|
+
else if (s.timeout < sup.timeout[0] || s.timeout > sup.timeout[1]) {
|
|
871
|
+
out.push(`${at}.timeout: ${label} takes ${sup.timeout[0]}s to ${sup.timeout[1]}s, not ${s.timeout}s.`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
for (const b of s.block ?? []) {
|
|
875
|
+
if (!sup.block?.includes(b)) out.push(`${at}.block: ${label} cannot block ${b}. These can: ${who((x) => x.block?.includes(b))}.`);
|
|
876
|
+
}
|
|
877
|
+
if (s.options !== undefined && !sup.options) out.push(`${at}.options: ${label} takes no options beyond these.`);
|
|
878
|
+
return out;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export function readBrowseSettings(raw: unknown): { settings: BrowseSettings; rest: unknown; error?: string } {
|
|
882
|
+
// An empty leftover is nothing at all. Returned as `{}` it reads to the
|
|
883
|
+
// vendor resolver as "the long form, with no name", which is a second
|
|
884
|
+
// error about a key the person never wrote.
|
|
885
|
+
const left = (o: Record<string, unknown>) => (Object.keys(o).length ? o : undefined);
|
|
886
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { settings: {}, rest: raw };
|
|
887
|
+
const o = raw as Record<string, unknown>;
|
|
888
|
+
const settings: BrowseSettings = {};
|
|
889
|
+
const rest: Record<string, unknown> = {};
|
|
890
|
+
for (const [k, v] of Object.entries(o)) {
|
|
891
|
+
if (!(BROWSE_SETTING_KEYS as readonly string[]).includes(k)) {
|
|
892
|
+
rest[k] = v;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (v === undefined || v === null || v === "") continue;
|
|
896
|
+
if (k === "identities") {
|
|
897
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
898
|
+
return { settings, rest: left(rest), error: "web.browse.identities must be a map of name to settings, like { au-mobile: { device: \"Pixel 7\", locale: en-AU } }." };
|
|
899
|
+
}
|
|
900
|
+
const identities: Record<string, Record<string, string>> = {};
|
|
901
|
+
for (const [name, block] of Object.entries(v as Record<string, unknown>)) {
|
|
902
|
+
const read = readIdentity(name, block);
|
|
903
|
+
if (read.error) return { settings, rest: left(rest), error: read.error };
|
|
904
|
+
identities[name] = read.identity!;
|
|
905
|
+
}
|
|
906
|
+
if (Object.keys(identities).length) settings.identities = identities;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
// The one setting that is a yes or no. YAML gives a boolean; a quoted
|
|
910
|
+
// "false" is the same wish and is read as one.
|
|
911
|
+
if (k === "headless") {
|
|
912
|
+
const b = v === true || v === "true" ? true : v === false || v === "false" ? false : undefined;
|
|
913
|
+
if (b === undefined) return { settings, rest: left(rest), error: `web.browse.headless is true or false, not ${JSON.stringify(v)}.` };
|
|
914
|
+
settings.headless = b;
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (k === "live" || k === "boundaries" || k === "webgpu" || k === "ignore_https_errors" || k === "video") {
|
|
918
|
+
const b = v === true || v === "true" ? true : v === false || v === "false" ? false : undefined;
|
|
919
|
+
if (b === undefined) return { settings, rest: left(rest), error: `web.browse.${k} is true or false, not ${JSON.stringify(v)}.` };
|
|
920
|
+
if (b) settings[k] = true;
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
// The vendor's own session. `session:` is the old spelling: a call's
|
|
924
|
+
// `session=` is a named saved login, a different thing under the same
|
|
925
|
+
// word, so the block key became vendor_session. Both read; one only.
|
|
926
|
+
if (k === "vendor_session" || k === "session") {
|
|
927
|
+
if (o.vendor_session !== undefined && o.session !== undefined && o.vendor_session !== null && o.session !== null) {
|
|
928
|
+
return { settings, rest: left(rest), error: "web.browse: vendor_session: and session: are the same block (session: is its old name) — keep vendor_session: only." };
|
|
929
|
+
}
|
|
930
|
+
const r = readBrowseSession(v, k);
|
|
931
|
+
if (r.error) return { settings, rest: left(rest), error: r.error };
|
|
932
|
+
settings.vendor_session = r.session;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
// The one that is on by default: only false travels.
|
|
936
|
+
if (k === "live_view") {
|
|
937
|
+
const b = v === true || v === "true" ? true : v === false || v === "false" ? false : undefined;
|
|
938
|
+
if (b === undefined) return { settings, rest: left(rest), error: `web.browse.live_view is true or false, not ${JSON.stringify(v)}.` };
|
|
939
|
+
if (!b) settings.live_view = false;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
if (k === "allowed_domains") {
|
|
943
|
+
const list = browseList(v)?.map((d) => d.toLowerCase());
|
|
944
|
+
if (!list) return { settings, rest: left(rest), error: "web.browse.allowed_domains is a list of domains, like [example.com, \"*.example.com\"]." };
|
|
945
|
+
const bad = list.find((d) => !BROWSE_DOMAIN.test(d));
|
|
946
|
+
if (bad) return { settings, rest: left(rest), error: `web.browse.allowed_domains: ${bad} is not a domain or *.domain — no scheme, no path.` };
|
|
947
|
+
if (list.length) settings.allowed_domains = list;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (k === "deny") {
|
|
951
|
+
const list = browseList(v)?.map((d) => d.toLowerCase());
|
|
952
|
+
if (!list) return { settings, rest: left(rest), error: "web.browse.deny is a list of actions, like [eval, download]." };
|
|
953
|
+
const known = new Set<string>([...WEB_BROWSE_ACTIONS, "js"]);
|
|
954
|
+
const bad = list.find((a) => !known.has(a));
|
|
955
|
+
if (bad) return { settings, rest: left(rest), error: `web.browse.deny: ${bad} is not a browse action — a misspelt denial would deny nothing. The actions are ${[...known].join(", ")}.` };
|
|
956
|
+
if (list.length) settings.deny = list;
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (k === "init" || k === "extensions") {
|
|
960
|
+
const list = browseList(v);
|
|
961
|
+
if (!list) return { settings, rest: left(rest), error: `web.browse.${k} is a list of workspace paths, like [${k === "init" ? "scripts/stub.js" : "library/extensions/my-ext"}].` };
|
|
962
|
+
const bad = list.find((p) => p.startsWith("/") || p.split(/[\\/]/).includes("..") || p.includes(","));
|
|
963
|
+
if (bad) return { settings, rest: left(rest), error: `web.browse.${k}: ${bad} must be a path inside the workspace (no leading /, no .., no comma).` };
|
|
964
|
+
if (list.length) settings[k] = list;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (k === "state_key") {
|
|
968
|
+
if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v)) return { settings, rest: left(rest), error: "web.browse.state_key must be the NAME of a vault secret (CAPITALS), never the key — store it with `foldrun secrets set NAME` and declare it under secrets:." };
|
|
969
|
+
settings.state_key = v;
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
if (k === "version") {
|
|
973
|
+
const val = String(v).trim();
|
|
974
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(val)) return { settings, rest: left(rest), error: `web.browse.version: ${JSON.stringify(v)} — a plain label like stable, beta, dev, or a build name (letters, digits, dot, dash, underscore).` };
|
|
975
|
+
settings.version = val;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (typeof v !== "string") {
|
|
979
|
+
return { settings, rest: left(rest), error: `web.browse.${k} must be text, not ${Array.isArray(v) ? "a list" : typeof v}.` };
|
|
980
|
+
}
|
|
981
|
+
if (k === "engine") {
|
|
982
|
+
const canonical = BROWSE_ENGINE_ALIASES[v.toLowerCase()];
|
|
983
|
+
if (!canonical) {
|
|
984
|
+
return { settings, rest: left(rest), error: `web.browse.engine: ${v} — the browsers are ${BROWSE_ENGINES.join(", ")}.` };
|
|
985
|
+
}
|
|
986
|
+
settings.engine = canonical;
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
// A vault name, not a cookie. Someone will eventually paste the header
|
|
990
|
+
// line straight into the file; it is refused here, where the mistake is
|
|
991
|
+
// still private, rather than committed and read by everyone with the repo.
|
|
992
|
+
if (k === "storage" && !/^[A-Z][A-Z0-9_]*$/.test(v)) {
|
|
993
|
+
return {
|
|
994
|
+
settings,
|
|
995
|
+
rest: left(rest),
|
|
996
|
+
error:
|
|
997
|
+
`web.browse.storage must be the NAME of a vault secret (CAPITALS), not the storage itself — ` +
|
|
998
|
+
`store the JSON with \`foldrun secrets set NAME\` and write \`storage: NAME\`.`,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
// An origin, not a cookie domain: Web Storage and IndexedDB are walled off
|
|
1002
|
+
// per origin, scheme and host together, so `.example.com` cannot be seeded.
|
|
1003
|
+
if (k === "storage_origin" && !/^https?:\/\/[^/\s]+$/.test(v)) {
|
|
1004
|
+
return {
|
|
1005
|
+
settings,
|
|
1006
|
+
rest: left(rest),
|
|
1007
|
+
error:
|
|
1008
|
+
`web.browse.storage_origin must be an origin, scheme and host with no path — ` +
|
|
1009
|
+
`write \`storage_origin: https://www.example.com\`, not \`.example.com\`.`,
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
if (k === "cookies" && !/^[A-Z][A-Z0-9_]*$/.test(v)) {
|
|
1013
|
+
return {
|
|
1014
|
+
settings,
|
|
1015
|
+
rest: left(rest),
|
|
1016
|
+
error:
|
|
1017
|
+
`web.browse.cookies must be the NAME of a vault secret (CAPITALS), not the cookies themselves — ` +
|
|
1018
|
+
`store them with \`foldrun secrets set NAME\` and write \`cookies: NAME\`.`,
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
(settings as Record<string, string>)[k] = v;
|
|
1022
|
+
}
|
|
1023
|
+
// A cookie default without a domain would ride on whatever host the call
|
|
1024
|
+
// opens, which is one agent handing a site someone else's session. The
|
|
1025
|
+
// domain is what keeps a file-level cookie to the site it belongs to.
|
|
1026
|
+
if (settings.cookies && !settings.cookie_domain) {
|
|
1027
|
+
return {
|
|
1028
|
+
settings,
|
|
1029
|
+
rest: left(rest),
|
|
1030
|
+
error:
|
|
1031
|
+
`web.browse.cookies needs web.browse.cookie_domain beside it — a cookie default with no domain ` +
|
|
1032
|
+
`would be sent to whatever site the call opens. Write \`cookie_domain: .example.com\`.`,
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Storage is per origin, and seeding it into the wrong one would hand a
|
|
1037
|
+
// site another site's signed-in state — the same mistake `cookie_domain`
|
|
1038
|
+
// exists to prevent, one storage area over.
|
|
1039
|
+
if (settings.storage && !settings.storage_origin) {
|
|
1040
|
+
return {
|
|
1041
|
+
settings,
|
|
1042
|
+
rest: left(rest),
|
|
1043
|
+
error:
|
|
1044
|
+
`web.browse.storage needs web.browse.storage_origin beside it — Web Storage and IndexedDB are ` +
|
|
1045
|
+
`walled off per origin, so the tool has to be told which one. Write \`storage_origin: https://www.example.com\`.`,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// `via:` is the readable name for what used to be the whole value.
|
|
1050
|
+
if (typeof rest.via === "string" && rest.name === undefined) {
|
|
1051
|
+
rest.name = rest.via;
|
|
1052
|
+
delete rest.via;
|
|
1053
|
+
}
|
|
1054
|
+
return { settings, rest: Object.keys(rest).length ? rest : undefined };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* `web.search:` settings — what the account's own engine (SearXNG) is asked,
|
|
1059
|
+
* not who answers.
|
|
1060
|
+
*
|
|
1061
|
+
* web:
|
|
1062
|
+
* search:
|
|
1063
|
+
* engines: [bing, google cse, duckduckgo]
|
|
1064
|
+
* categories: [general, news]
|
|
1065
|
+
* safesearch: moderate
|
|
1066
|
+
*
|
|
1067
|
+
* Every SearXNG request option that changes what comes back has a key here;
|
|
1068
|
+
* the ones that only change its HTML page (theme, results_on_new_tab,
|
|
1069
|
+
* image_proxy, url_formatting) do not, because in JSON they do nothing and a
|
|
1070
|
+
* key that does nothing is a question someone asks later. Engine and
|
|
1071
|
+
* category names are checked for shape here and against the running
|
|
1072
|
+
* instance by the tool (its /config), because the instance decides which of
|
|
1073
|
+
* SearXNG's engines are loaded. Plugin ids are a short list that changes
|
|
1074
|
+
* rarely, so a misspelt one — which would enable nothing — is refused here.
|
|
1075
|
+
*
|
|
1076
|
+
* The settings speak SearXNG, so they are refused beside `name:` — with
|
|
1077
|
+
* `web.search: exa` they would be silently ignored.
|
|
1078
|
+
*/
|
|
1079
|
+
export interface SearchSettings {
|
|
1080
|
+
/** Ask only these engines. A default: a call's `engines=` or
|
|
1081
|
+
* `categories=` replaces it, and it beats `categories` beside it — the
|
|
1082
|
+
* tool never sends SearXNG both, which would add the categories' engines
|
|
1083
|
+
* to the list. */
|
|
1084
|
+
engines?: string[];
|
|
1085
|
+
/** Never ask these, whatever the call says. A lock, like
|
|
1086
|
+
* web.browse.allowed_domains: a call may not widen it. */
|
|
1087
|
+
exclude_engines?: string[];
|
|
1088
|
+
/** The SearXNG categories to search (general, news, science, it, …),
|
|
1089
|
+
* when no engine list is in force. A default a call replaces. */
|
|
1090
|
+
categories?: string[];
|
|
1091
|
+
/** 0 off, 1 moderate, 2 strict. */
|
|
1092
|
+
safesearch?: 0 | 1 | 2;
|
|
1093
|
+
/** The default recency when a call does not say. */
|
|
1094
|
+
time_range?: "day" | "week" | "month" | "year";
|
|
1095
|
+
/** Seconds SearXNG waits for its engines before answering with what it has. */
|
|
1096
|
+
timeout?: number;
|
|
1097
|
+
/** SearXNG plugins to switch on, and off, for this agent's searches. */
|
|
1098
|
+
plugins?: string[];
|
|
1099
|
+
exclude_plugins?: string[];
|
|
1100
|
+
/** Where the open-access DOI rewrite plugin points a paper's link. */
|
|
1101
|
+
doi_resolver?: string;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** SearXNG's plugin ids (searx/plugins/*.py, 2026-09-29). The gallery test
|
|
1105
|
+
* keeps this equal to the list the tool documents. */
|
|
1106
|
+
export const WEB_SEARCH_PLUGINS = [
|
|
1107
|
+
"ahmia_filter", "calculator", "hash_plugin", "hostnames", "infinite_scroll", "oa_doi_rewrite",
|
|
1108
|
+
"self_info", "time_zone", "tor_check", "tracker_url_remover", "unit_converter",
|
|
1109
|
+
] as const;
|
|
1110
|
+
const SEARCH_SETTING_KEYS = [
|
|
1111
|
+
"engines", "exclude_engines", "categories", "safesearch", "time_range", "timeout", "plugins", "exclude_plugins", "doi_resolver",
|
|
1112
|
+
] as const;
|
|
1113
|
+
// SearXNG names engines the way people do — "google cse", "wikicommons.images",
|
|
1114
|
+
// "yandex api" — so a space and a dot are allowed. A comma is not: the tool
|
|
1115
|
+
// sends the list comma-joined, and one inside a name would split it.
|
|
1116
|
+
const SEARCH_ENGINE_NAME = /^[a-z0-9][a-z0-9 ._-]{0,63}$/i;
|
|
1117
|
+
const SEARCH_CATEGORY_NAME = /^[a-z][a-z0-9 _-]{0,31}$/i;
|
|
1118
|
+
const SAFESEARCH_WORDS: Record<string, 0 | 1 | 2> = { off: 0, none: 0, moderate: 1, strict: 2 };
|
|
1119
|
+
|
|
1120
|
+
/** The settings in a `web.search:` block, and the provider part with them
|
|
1121
|
+
* removed — the shape readBrowseSettings has, for the same reason. */
|
|
1122
|
+
export function readSearchSettings(raw: unknown): { settings: SearchSettings; rest: unknown; error?: string } {
|
|
1123
|
+
const left = (o: Record<string, unknown>) => (Object.keys(o).length ? o : undefined);
|
|
1124
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { settings: {}, rest: raw };
|
|
1125
|
+
const settings: SearchSettings = {};
|
|
1126
|
+
const rest: Record<string, unknown> = {};
|
|
1127
|
+
const fail = (error: string) => ({ settings, rest: left(rest), error });
|
|
1128
|
+
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
1129
|
+
if (!(SEARCH_SETTING_KEYS as readonly string[]).includes(k)) {
|
|
1130
|
+
rest[k] = v;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
if (v === undefined || v === null || v === "") continue;
|
|
1134
|
+
if (k === "engines" || k === "exclude_engines") {
|
|
1135
|
+
const list = browseList(v)?.map((e) => e.toLowerCase());
|
|
1136
|
+
if (!list) return fail(`web.search.${k} is a list of SearXNG engine names, like [bing, google cse, duckduckgo].`);
|
|
1137
|
+
const bad = list.find((e) => !SEARCH_ENGINE_NAME.test(e));
|
|
1138
|
+
if (bad !== undefined) return fail(`web.search.${k}: ${JSON.stringify(bad)} is not an engine name — letters, digits, space, dot, dash.`);
|
|
1139
|
+
if (list.length) settings[k] = [...new Set(list)];
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (k === "categories") {
|
|
1143
|
+
const list = browseList(v)?.map((c) => c.toLowerCase());
|
|
1144
|
+
if (!list) return fail("web.search.categories is a list of SearXNG categories, like [general, news].");
|
|
1145
|
+
const bad = list.find((c) => !SEARCH_CATEGORY_NAME.test(c));
|
|
1146
|
+
if (bad !== undefined) return fail(`web.search.categories: ${JSON.stringify(bad)} is not a category name.`);
|
|
1147
|
+
if (list.length) settings.categories = [...new Set(list)];
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
if (k === "plugins" || k === "exclude_plugins") {
|
|
1151
|
+
const list = browseList(v)?.map((p) => p.toLowerCase());
|
|
1152
|
+
if (!list) return fail(`web.search.${k} is a list of SearXNG plugin ids, like [oa_doi_rewrite].`);
|
|
1153
|
+
// Matched without underscores: SearXNG has respelt ids between releases
|
|
1154
|
+
// (infiniteScroll → infinite_scroll), and the tool sends whichever
|
|
1155
|
+
// spelling the running instance uses.
|
|
1156
|
+
const bare = (p: string) => p.replace(/_/g, "");
|
|
1157
|
+
const bad = list.find((p) => !WEB_SEARCH_PLUGINS.some((id) => bare(id) === bare(p)));
|
|
1158
|
+
if (bad !== undefined) {
|
|
1159
|
+
return fail(`web.search.${k}: ${bad} is not a SearXNG plugin — a misspelt one would switch nothing. The plugins are ${WEB_SEARCH_PLUGINS.join(", ")}.`);
|
|
1160
|
+
}
|
|
1161
|
+
if (list.length) settings[k] = [...new Set(list)];
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (k === "safesearch") {
|
|
1165
|
+
const n = typeof v === "number" ? v : typeof v === "string" ? (SAFESEARCH_WORDS[v.trim().toLowerCase()] ?? (/^[012]$/.test(v.trim()) ? Number(v) : NaN)) : NaN;
|
|
1166
|
+
if (n !== 0 && n !== 1 && n !== 2) return fail(`web.search.safesearch is off, moderate or strict (or 0, 1, 2), not ${JSON.stringify(v)}.`);
|
|
1167
|
+
settings.safesearch = n;
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
if (k === "time_range") {
|
|
1171
|
+
const r = String(v).trim().toLowerCase();
|
|
1172
|
+
if (r !== "day" && r !== "week" && r !== "month" && r !== "year") return fail(`web.search.time_range is day, week, month or year, not ${JSON.stringify(v)}.`);
|
|
1173
|
+
settings.time_range = r;
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
if (k === "timeout") {
|
|
1177
|
+
const n = typeof v === "number" ? v : Number(String(v).replace(/s$/, ""));
|
|
1178
|
+
if (!Number.isFinite(n) || n < 0.5 || n > 30) return fail(`web.search.timeout is seconds, 0.5 to 30, not ${JSON.stringify(v)}.`);
|
|
1179
|
+
settings.timeout = n;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
// doi_resolver: a host SearXNG knows (oadoi.org, doi.org, …), checked
|
|
1183
|
+
// against the instance by the tool; here only that it is a host.
|
|
1184
|
+
if (typeof v !== "string" || !BROWSE_DOMAIN.test(v.trim().toLowerCase())) {
|
|
1185
|
+
return fail(`web.search.doi_resolver is a resolver's host, like oadoi.org or doi.org, not ${JSON.stringify(v)}.`);
|
|
1186
|
+
}
|
|
1187
|
+
settings.doi_resolver = v.trim().toLowerCase();
|
|
1188
|
+
}
|
|
1189
|
+
const both = (settings.engines ?? []).find((e) => settings.exclude_engines?.includes(e));
|
|
1190
|
+
if (both) return fail(`web.search: ${both} is in both engines and exclude_engines — say which.`);
|
|
1191
|
+
const bothP = (settings.plugins ?? []).find((p) => settings.exclude_plugins?.includes(p));
|
|
1192
|
+
if (bothP) return fail(`web.search: ${bothP} is in both plugins and exclude_plugins — say which.`);
|
|
1193
|
+
if (Object.keys(settings).length && (rest.name !== undefined || rest.key !== undefined)) {
|
|
1194
|
+
return fail(
|
|
1195
|
+
`web.search: ${Object.keys(settings).join(", ")} ${Object.keys(settings).length === 1 ? "is a setting" : "are settings"} for the account's own engine (SearXNG); ` +
|
|
1196
|
+
`beside name: ${String(rest.name)} ${Object.keys(settings).length === 1 ? "it" : "they"} would do nothing. Drop name: to use them, or drop them to use ${String(rest.name)}.`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return { settings, rest: left(rest) };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** The `web.search:` block's settings as the env the tool reads. Only what
|
|
1203
|
+
* was said travels — an unset key is SearXNG's own default. */
|
|
1204
|
+
export function searchSettingsEnv(s: SearchSettings): Record<string, string> {
|
|
1205
|
+
return {
|
|
1206
|
+
...(s.engines ? { FOLDRUN_WEB_SEARCH_ENGINES: s.engines.join(",") } : {}),
|
|
1207
|
+
...(s.exclude_engines ? { FOLDRUN_WEB_SEARCH_EXCLUDE_ENGINES: s.exclude_engines.join(",") } : {}),
|
|
1208
|
+
...(s.categories ? { FOLDRUN_WEB_SEARCH_CATEGORIES: s.categories.join(",") } : {}),
|
|
1209
|
+
...(s.safesearch !== undefined ? { FOLDRUN_WEB_SEARCH_SAFESEARCH: String(s.safesearch) } : {}),
|
|
1210
|
+
...(s.time_range ? { FOLDRUN_WEB_SEARCH_TIME_RANGE: s.time_range } : {}),
|
|
1211
|
+
...(s.timeout !== undefined ? { FOLDRUN_WEB_SEARCH_TIMEOUT: String(s.timeout) } : {}),
|
|
1212
|
+
...(s.plugins ? { FOLDRUN_WEB_SEARCH_PLUGINS: s.plugins.join(",") } : {}),
|
|
1213
|
+
...(s.exclude_plugins ? { FOLDRUN_WEB_SEARCH_EXCLUDE_PLUGINS: s.exclude_plugins.join(",") } : {}),
|
|
1214
|
+
...(s.doi_resolver ? { FOLDRUN_WEB_SEARCH_DOI_RESOLVER: s.doi_resolver } : {}),
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function readChoice(raw: unknown, field: string): { name: string; secret?: string } | { error: string } {
|
|
1219
|
+
if (typeof raw === "string") return { name: raw };
|
|
1220
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1221
|
+
const o = raw as Record<string, unknown>;
|
|
1222
|
+
if (typeof o.name !== "string" || !o.name.trim()) {
|
|
1223
|
+
return { error: `${field}: the long form needs \`name:\` — the API or provider to ask.` };
|
|
1224
|
+
}
|
|
1225
|
+
if (o.key === undefined) return { name: o.name };
|
|
1226
|
+
if (typeof o.key !== "string") return { error: `${field}.key must be a \${NAME} reference to a secret in the vault.` };
|
|
1227
|
+
const m = /^\$\{([A-Z][A-Z0-9_]*)\}$/.exec(o.key.trim());
|
|
1228
|
+
if (!m) {
|
|
1229
|
+
return {
|
|
1230
|
+
error:
|
|
1231
|
+
`${field}.key must be a \${NAME} reference to a secret in the vault, not the key itself — ` +
|
|
1232
|
+
`store it with \`foldrun secrets set NAME\` and write \`key: \${NAME}\`.`,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return { name: o.name, secret: m[1] };
|
|
1236
|
+
}
|
|
1237
|
+
return { error: `${field}: takes a name, or a block with name: and key:, or nothing at all for the runtime's own.` };
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
export function resolveSearch(name: unknown, kind: "search" | "fetch" | "browse" = "search"): SearchChoice {
|
|
1241
|
+
const field = kind === "fetch" ? "web.fetch" : kind === "browse" ? "web.browse" : "web.search";
|
|
1242
|
+
if (name === undefined || name === null || name === "" || name === "ours") {
|
|
1243
|
+
return { provider: null };
|
|
1244
|
+
}
|
|
1245
|
+
const read = readChoice(name, field);
|
|
1246
|
+
if ("error" in read) return { provider: null, error: read.error };
|
|
1247
|
+
const key = read.name.trim().toLowerCase();
|
|
1248
|
+
|
|
1249
|
+
if (key in REFUSED_WEB) return { provider: null, error: `${field}: ${key} — ${REFUSED_WEB[key]}` };
|
|
1250
|
+
if (kind === "browse") {
|
|
1251
|
+
const b = findBrowserApi(key);
|
|
1252
|
+
if (!b) {
|
|
1253
|
+
return { provider: null, error: `web.browse: ${key} — no remote browser by that name. The ones this tool can connect to: ${BROWSER_APIS.map((a) => a.name).join(", ")}. Unset means the account's own browser.` };
|
|
1254
|
+
}
|
|
1255
|
+
return { provider: b.name, shape: "direct", index: b.what, secret: read.secret ?? b.secret, secretOptional: false, host: b.host };
|
|
1256
|
+
}
|
|
1257
|
+
const api = kind === "fetch" ? findFetchApi(key) : findSearchApi(key);
|
|
1258
|
+
if (api) {
|
|
1259
|
+
return {
|
|
1260
|
+
provider: api.name,
|
|
1261
|
+
shape: "direct",
|
|
1262
|
+
index: "index" in api ? (api as SearchApi).index : (api as FetchApi).what,
|
|
1263
|
+
secret: read.secret ?? api.secret,
|
|
1264
|
+
// A custom vault name is a deliberate choice; it is never optional.
|
|
1265
|
+
secretOptional: read.secret ? false : Boolean(api.secretOptional),
|
|
1266
|
+
host: api.host,
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
if (kind === "fetch") {
|
|
1270
|
+
const other = findSearchApi(key);
|
|
1271
|
+
if (other) {
|
|
1272
|
+
return { provider: null, error: `web.fetch: ${key} — ${other.title} searches but has no fetch here. The fetch APIs: ${FETCH_APIS.map((a) => a.name).join(", ")}; or anthropic.` };
|
|
1273
|
+
}
|
|
1274
|
+
if (key === "anthropic") return { provider: "anthropic", shape: "anthropic", index: "Anthropic's web_fetch — a small model's reading of the page, on Anthropic's servers" };
|
|
1275
|
+
const preset = PROVIDERS.find((p) => p.name === key);
|
|
1276
|
+
return {
|
|
1277
|
+
provider: null,
|
|
1278
|
+
error: preset
|
|
1279
|
+
? `web.fetch: ${key} — ${preset.title} has no fetch a tool can call. The fetch APIs: ${FETCH_APIS.map((a) => a.name).join(", ")}; or anthropic.`
|
|
1280
|
+
: `web.fetch: ${key} — no fetch API or provider by that name. The fetch APIs: ${FETCH_APIS.map((a) => a.name).join(", ")}; or anthropic.`,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
if (read.secret) {
|
|
1284
|
+
return { provider: null, error: `${field}.key: only a direct API takes your own key here. ${key} is a model provider — its key lives in the provider: block.` };
|
|
1285
|
+
}
|
|
1286
|
+
const preset = PROVIDERS.find((p) => p.name === key);
|
|
1287
|
+
if (!preset) {
|
|
1288
|
+
const providers = PROVIDERS.filter((p) => p.search).map((p) => p.name).join(", ");
|
|
1289
|
+
const apis = SEARCH_APIS.map((a) => a.name).join(", ");
|
|
1290
|
+
return { provider: null, error: `${field}: ${key} — no provider or search API by that name. Providers that search: ${providers}. Search APIs, with your own key: ${apis}.` };
|
|
1291
|
+
}
|
|
1292
|
+
if (!preset.search) {
|
|
1293
|
+
return {
|
|
1294
|
+
provider: null,
|
|
1295
|
+
error:
|
|
1296
|
+
`web.search: ${key} — ${preset.title} has no server-side search. ` +
|
|
1297
|
+
`Its endpoint is ${preset.format}-shaped, but speaking a wire is not the same as running a tool on it. ` +
|
|
1298
|
+
`Leave web.search: unset to use the account's own search engine.`,
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
return { provider: key, shape: preset.search, index: SEARCH_INDEX[key] };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** A `web.browse:` block that pairs an engine with something it cannot do,
|
|
1305
|
+
* in the tool's own words (BROWSE_ENGINE_LIMITS). The block's engine is
|
|
1306
|
+
* checked against the block's settings; each identity is checked with its
|
|
1307
|
+
* own engine (or the block's) against the block's settings and its own,
|
|
1308
|
+
* because a call that picks the identity gets both. What only a call can
|
|
1309
|
+
* ask for (a mode, a trace, mocks) is the tool's to refuse. */
|
|
1310
|
+
export function browseEngineProblems(settings: BrowseSettings): string[] {
|
|
1311
|
+
const fromBlock: [string, string][] = [];
|
|
1312
|
+
if (settings.video) fromBlock.push(["video", "video"]);
|
|
1313
|
+
if (settings.device) fromBlock.push(["device", "device"]);
|
|
1314
|
+
if (settings.live) fromBlock.push(["live", "live"]);
|
|
1315
|
+
if (settings.webgpu) fromBlock.push(["webgpu", "webgpu"]);
|
|
1316
|
+
if (settings.extensions?.length) fromBlock.push(["extensions", "extensions"]);
|
|
1317
|
+
if (settings.allowed_domains?.length) fromBlock.push(["allowed_domains", "allowed_domains"]);
|
|
1318
|
+
if (settings.headless === false) fromBlock.push(["headless", "headless: false"]);
|
|
1319
|
+
const out: string[] = [];
|
|
1320
|
+
const engine = settings.engine ?? "chromium";
|
|
1321
|
+
for (const [key, what] of fromBlock) {
|
|
1322
|
+
const no = browseEngineRefusal(engine, what);
|
|
1323
|
+
if (no) out.push(`web.browse.${key}: ${no}.`);
|
|
1324
|
+
}
|
|
1325
|
+
for (const [name, id] of Object.entries(settings.identities ?? {})) {
|
|
1326
|
+
const eng = id.engine ?? engine;
|
|
1327
|
+
const own: [string, string][] = (["device", "proxy", "block"] as const).filter((k) => id[k]).map((k) => [k, k]);
|
|
1328
|
+
for (const [key, what] of own) {
|
|
1329
|
+
const no = browseEngineRefusal(eng, what);
|
|
1330
|
+
if (no) out.push(`web.browse.identities.${name}.${key}: ${no}.`);
|
|
1331
|
+
}
|
|
1332
|
+
// The block's settings ride on every call, so an identity that changes
|
|
1333
|
+
// the engine must be able to do them too.
|
|
1334
|
+
if (id.engine && id.engine !== engine) {
|
|
1335
|
+
for (const [key, what] of fromBlock) {
|
|
1336
|
+
if (id[key]) continue;
|
|
1337
|
+
const no = browseEngineRefusal(eng, what);
|
|
1338
|
+
if (no) out.push(`web.browse.identities.${name}: engine ${eng} with web.browse.${key} — ${no}.`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return out;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Every `web:` value in a frontmatter that cannot work, each as the
|
|
1346
|
+
* sentence resolveSearch gives. Computed once in core so `check`, the
|
|
1347
|
+
* deploy gate and the run all refuse the same thing in the same words —
|
|
1348
|
+
* the timezone rule's shape. Empty when all are unset or answerable. */
|
|
1349
|
+
export function webProblems(front: Record<string, unknown>): string[] {
|
|
1350
|
+
const web = webConfig(front);
|
|
1351
|
+
const out: string[] = [...web.problems];
|
|
1352
|
+
for (const kind of ["search", "fetch", "browse"] as const) {
|
|
1353
|
+
let value = web.raw[kind];
|
|
1354
|
+
let browse: BrowseSettings | null = null;
|
|
1355
|
+
if (kind === "browse" || kind === "search") {
|
|
1356
|
+
const read = kind === "browse" ? readBrowseSettings(value) : readSearchSettings(value);
|
|
1357
|
+
if (read.error) {
|
|
1358
|
+
out.push(read.error);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
if (kind === "browse") {
|
|
1362
|
+
browse = read.settings as BrowseSettings;
|
|
1363
|
+
out.push(...browseEngineProblems(browse));
|
|
1364
|
+
}
|
|
1365
|
+
value = read.rest;
|
|
1366
|
+
}
|
|
1367
|
+
const choice = resolveSearch(value, kind);
|
|
1368
|
+
if (choice.error) out.push(choice.error);
|
|
1369
|
+
else if (browse) out.push(...browseSessionProblems(browse, choice.provider));
|
|
1370
|
+
}
|
|
1371
|
+
return out;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* The engines the runner image was built with, as the deploy wrote them into
|
|
1376
|
+
* FOLDRUN_RUNNER_ENGINES (foldrun-infra dev/ci/runner-engines.sh): a comma
|
|
1377
|
+
* list of names, each with `=<version>` where the image's manifest
|
|
1378
|
+
* (/opt/browser/engines.json) has a dotted version —
|
|
1379
|
+
* `chromium=153.0.8010.12,chrome=154.0.8037.58,firefox,lightpanda=0.4.1`.
|
|
1380
|
+
* Null when unset.
|
|
1381
|
+
*/
|
|
1382
|
+
export function runnerEngines(raw: string | undefined = process.env.FOLDRUN_RUNNER_ENGINES): Record<string, string | null> | null {
|
|
1383
|
+
const out: Record<string, string | null> = {};
|
|
1384
|
+
for (const part of (raw ?? "").split(",")) {
|
|
1385
|
+
const m = /^\s*([a-z][a-z0-9-]{0,31})(?:=(\d+(?:\.\d+)+))?\s*$/.exec(part);
|
|
1386
|
+
if (m) out[m[1]] = m[2] ?? null;
|
|
1387
|
+
}
|
|
1388
|
+
return Object.keys(out).length ? out : null;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** The Chrome major a user-agent string claims — `Chrome/153.0.0.0` → 153 —
|
|
1392
|
+
* or null for one that names no Chrome (Firefox, Safari, an iPhone). */
|
|
1393
|
+
export function chromeMajorOf(ua: string): number | null {
|
|
1394
|
+
if (/\b(Firefox|FxiOS|CriOS|Edg)\//.test(ua)) return null;
|
|
1395
|
+
const m = /Chrome\/(\d+)\./.exec(ua); // HeadlessChrome/ too
|
|
1396
|
+
return m ? Number(m[1]) : null;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/**
|
|
1400
|
+
* A pinned `user_agent:` that claims another Chrome than the one that will
|
|
1401
|
+
* send it. The tool's own default follows the installed Chrome (its
|
|
1402
|
+
* chromiumVersion()), so only a pinned string can drift — and it drifts
|
|
1403
|
+
* every time the image's Chrome moves (stable is unpinned, a new major every
|
|
1404
|
+
* four weeks). A site then sees `Chrome/153` in the User-Agent header beside
|
|
1405
|
+
* `"Google Chrome";v="154"` in the client hints the browser still sends, and
|
|
1406
|
+
* a JavaScript engine that is 154's: the mismatch bot checks look for.
|
|
1407
|
+
* `chrome` is the image's version of that browser (runnerEngines().chrome,
|
|
1408
|
+
* or .chromium for engine chromium); null — an image that did not say —
|
|
1409
|
+
* warns about nothing.
|
|
1410
|
+
*/
|
|
1411
|
+
export function userAgentDrift(ua: string | undefined, chrome: string | null | undefined, where: string, browser = "Chrome"): string | null {
|
|
1412
|
+
if (!ua || !chrome) return null;
|
|
1413
|
+
const pinned = chromeMajorOf(ua);
|
|
1414
|
+
const have = Number(chrome.split(".")[0]);
|
|
1415
|
+
if (pinned === null || !Number.isFinite(have) || pinned === have) return null;
|
|
1416
|
+
return `${where} pins Chrome ${pinned}, and the runner image's ${browser} is ${have} — a site sees a user agent that disagrees with the browser's own client hints. Drop it (the default user agent follows the installed ${browser}), or raise it to Chrome/${have}.`;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* What in a `web:` block works today but should change — never a refusal.
|
|
1421
|
+
* `check` prints these as warnings and the deploy reports them beside its
|
|
1422
|
+
* outward-gate warnings. `opts.chrome` / `opts.chromium`: the runner image's
|
|
1423
|
+
* Google Chrome and Playwright Chromium versions, when known
|
|
1424
|
+
* (FOLDRUN_RUNNER_ENGINES on the platform, /api/version from the CLI); an
|
|
1425
|
+
* engine whose version is unknown is not compared.
|
|
1426
|
+
*/
|
|
1427
|
+
export function webWarnings(front: Record<string, unknown>, opts: { chrome?: string | null; chromium?: string | null } = {}): string[] {
|
|
1428
|
+
const out: string[] = [];
|
|
1429
|
+
const browse = webConfig(front).raw.browse;
|
|
1430
|
+
if (!browse || typeof browse !== "object" || Array.isArray(browse)) return out;
|
|
1431
|
+
const raw = browse as Record<string, unknown>;
|
|
1432
|
+
if (raw.session !== undefined && raw.session !== null && raw.vendor_session === undefined) {
|
|
1433
|
+
out.push("web.browse.session: is now vendor_session: — the same block, renamed so it is not mistaken for a call's session= (a named saved login). Rename the key; session: is still read for now.");
|
|
1434
|
+
}
|
|
1435
|
+
const read = readBrowseSettings(browse);
|
|
1436
|
+
if (read.error) return out;
|
|
1437
|
+
const engine = read.settings.engine ?? "chromium";
|
|
1438
|
+
// Only an engine that IS Chrome sends Chrome's client hints; a Chrome UA on
|
|
1439
|
+
// Firefox is a different (deliberate) disguise this does not judge. Each
|
|
1440
|
+
// engine against its own version: chromium is Playwright's build (1.63's is
|
|
1441
|
+
// 153), not the image's Google Chrome (stable, often a major ahead).
|
|
1442
|
+
const drift = (eng: string, ua: string | undefined, where: string) =>
|
|
1443
|
+
eng === "chrome" ? userAgentDrift(ua, opts.chrome, where, "Chrome")
|
|
1444
|
+
: eng === "chromium" ? userAgentDrift(ua, opts.chromium, where, "Chromium")
|
|
1445
|
+
: null;
|
|
1446
|
+
const w = drift(engine, read.settings.user_agent, "web.browse.user_agent");
|
|
1447
|
+
if (w) out.push(w);
|
|
1448
|
+
for (const [name, id] of Object.entries(read.settings.identities ?? {})) {
|
|
1449
|
+
const iw = drift(id.engine ?? engine, id.user_agent, `web.browse.identities.${name}.user_agent`);
|
|
1450
|
+
if (iw) out.push(iw);
|
|
1451
|
+
}
|
|
1452
|
+
return out;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/**
|
|
1456
|
+
* The agent's `web:` block, read once for everyone — the runner, `check`
|
|
1457
|
+
* and the deploy gate:
|
|
1458
|
+
*
|
|
1459
|
+
* tools: [web]
|
|
1460
|
+
* web:
|
|
1461
|
+
* actions: [search, fetch, crawl] # what it may do; all when absent
|
|
1462
|
+
* search: brave # who does each; absent is foldrun
|
|
1463
|
+
* browse: { engine: firefox } # search and browse also take their settings
|
|
1464
|
+
*
|
|
1465
|
+
* `raw` is each action's value as written. Browse alone cascades: the
|
|
1466
|
+
* workspace's (or account's) `web: {browse: …}` applies when the agent's
|
|
1467
|
+
* says nothing. A per-action key outside the block (`web_search:`) is not
|
|
1468
|
+
* read; it is a problem naming the block.
|
|
1469
|
+
*/
|
|
1470
|
+
export function webConfig(
|
|
1471
|
+
front: Record<string, unknown>,
|
|
1472
|
+
workspaceFront?: Record<string, unknown>,
|
|
1473
|
+
): {
|
|
1474
|
+
actions: WebAction[] | null;
|
|
1475
|
+
raw: Record<WebAction, unknown>;
|
|
1476
|
+
problems: string[];
|
|
1477
|
+
} {
|
|
1478
|
+
const problems: string[] = [];
|
|
1479
|
+
const block = front.web;
|
|
1480
|
+
let web: Record<string, unknown> = {};
|
|
1481
|
+
if (block !== undefined && block !== null) {
|
|
1482
|
+
if (typeof block === "object" && !Array.isArray(block)) web = block as Record<string, unknown>;
|
|
1483
|
+
else problems.push("web: is a block — actions: and a provider per action, e.g. `web: {fetch: jina}`.");
|
|
1484
|
+
}
|
|
1485
|
+
for (const k of Object.keys(web)) {
|
|
1486
|
+
if (k !== "actions" && !(WEB_ACTIONS as readonly string[]).includes(k)) {
|
|
1487
|
+
problems.push(`web.${k}: is not an action. The actions: ${WEB_ACTIONS.join(", ")}; and actions: for which of them the agent may use.`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
for (const a of WEB_ACTIONS) {
|
|
1491
|
+
if (front[`web_${a}`] !== undefined) problems.push(`web_${a}: is not a key — write \`web: {${a}: …}\`.`);
|
|
1492
|
+
}
|
|
1493
|
+
let actions: WebAction[] | null = null;
|
|
1494
|
+
if (web.actions !== undefined) {
|
|
1495
|
+
const list = Array.isArray(web.actions) ? web.actions.map((a) => String(a).trim().toLowerCase()) : null;
|
|
1496
|
+
const bad = list?.filter((a) => !(WEB_ACTIONS as readonly string[]).includes(a)) ?? [];
|
|
1497
|
+
if (!list) problems.push(`web.actions: is a list, e.g. [search, fetch].`);
|
|
1498
|
+
else if (bad.length) problems.push(`web.actions: ${bad.join(", ")} ${bad.length === 1 ? "is not an action" : "are not actions"}. The actions: ${WEB_ACTIONS.join(", ")}.`);
|
|
1499
|
+
else actions = [...new Set(list)] as WebAction[];
|
|
1500
|
+
}
|
|
1501
|
+
const wsWeb = workspaceFront?.web && typeof workspaceFront.web === "object" && !Array.isArray(workspaceFront.web)
|
|
1502
|
+
? (workspaceFront.web as Record<string, unknown>) : {};
|
|
1503
|
+
const raw = Object.fromEntries(WEB_ACTIONS.map((a) => {
|
|
1504
|
+
let v = web[a];
|
|
1505
|
+
if (v === undefined && a === "browse") v = wsWeb.browse;
|
|
1506
|
+
return [a, v];
|
|
1507
|
+
})) as Record<WebAction, unknown>;
|
|
1508
|
+
return { actions, raw, problems };
|
|
1509
|
+
}
|