@foldrun/core 0.3.0 → 0.5.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.
Files changed (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -29,19 +29,178 @@
29
29
  // whether a tool loop was actually driven through the endpoint from here,
30
30
  // which is the only claim that matters for an agent runtime; a name without
31
31
  // it is documented, not proven.
32
+ export const SEARCH_APIS = [
33
+ { 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",
34
+ method: "GET", auth: { header: "X-Subscription-Token" }, secret: "BRAVE_SEARCH_API_KEY",
35
+ index: "Brave's own — ~40B pages, ~100M refreshed a day; the index Claude searches" },
36
+ { 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",
37
+ method: "POST", auth: { header: "x-api-key" }, secret: "EXA_API_KEY",
38
+ index: "Exa's own semantic index — by meaning, not keywords" },
39
+ { 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",
40
+ method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "TAVILY_API_KEY",
41
+ index: "Tavily's own crawler plus bought-in feeds" },
42
+ { 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",
43
+ method: "POST", auth: { header: "x-api-key" }, secret: "PARALLEL_API_KEY",
44
+ index: "Parallel's own closed index",
45
+ note: "Wants an objective beside the queries; the wrapper writes one from the query." },
46
+ { 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",
47
+ method: "POST", auth: { header: "X-API-Key" }, secret: "YOU_API_KEY",
48
+ index: "You.com's own index and cache (self-reported)" },
49
+ { 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/",
50
+ method: "GET", auth: { header: "Authorization", prefix: "Bearer " }, secret: "JINA_API_KEY",
51
+ index: "Jina's — top results, each with its page content already read",
52
+ note: "s.jina.ai refuses without a key (checked live 2026-09-16); r.jina.ai, the reader, does not." },
53
+ { 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",
54
+ method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "FIRECRAWL_API_KEY",
55
+ index: "Firecrawl's — open-source core, self-hostable" },
56
+ { 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",
57
+ method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "PERPLEXITY_API_KEY",
58
+ index: "Perplexity's own crawl — the Search API returns results, not an answer" },
59
+ { 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",
60
+ method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "LINKUP_API_KEY",
61
+ index: "Linkup's — agent-shaped, each result with its content" },
62
+ // ---- the SERP scrapers: Google's own results page, read for you. Not
63
+ // an index of their own, and not Google partners — there is no such
64
+ // programme. The one kind to use when the question is about Google
65
+ // itself: where a page ranks, what the SERP shows.
66
+ { 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",
67
+ method: "POST", auth: { header: "X-API-KEY" }, secret: "SERPER_API_KEY",
68
+ index: "Google's results page, scraped — ~$1 / 1,000; fast, developers' favourite" },
69
+ { name: "serpapi", docs: "https://serpapi.com/search-api", checked: "2026-09-29", title: "SerpApi", host: "serpapi.com", endpoint: "https://serpapi.com/search",
70
+ method: "GET", auth: { header: "", prefix: "" }, secret: "SERPAPI_API_KEY",
71
+ index: "Google's results page, scraped — the dearest, 80+ engines",
72
+ note: "The key travels as the api_key query parameter; the egress proxy fills the URL as it fills a header." },
73
+ { 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",
74
+ method: "POST", auth: { header: "Authorization", prefix: "Basic " }, secret: "DATAFORSEO_AUTH_BASIC",
75
+ secretFormat: "base64 of `login:password` — DataForSEO authenticates with HTTP basic auth, and the proxy fills one placeholder verbatim: `printf 'LOGIN:PASSWORD' | base64`",
76
+ index: "Google's results page, scraped — $0.60 / 1,000 standard queue; what rank-desk uses" },
77
+ ];
78
+ /** Names people will try that cannot work, and why — said at `check`
79
+ * rather than discovered at 3am. */
80
+ export const REFUSED_WEB = {
81
+ 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).",
82
+ azure: "the Bing Search API is retired; Grounding with Bing Search runs only inside Azure AI agents. See bing.",
83
+ 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.",
84
+ 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.",
85
+ };
86
+ export const FETCH_APIS = [
87
+ { 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/",
88
+ method: "GET", auth: { header: "Authorization", prefix: "Bearer " }, secret: "JINA_API_KEY", secretOptional: true,
89
+ tier: "reader", batch: 1, what: "clean markdown, text or html; works without a key at 20 requests a minute (checked live 2026-09-16)" },
90
+ { 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",
91
+ method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "FIRECRAWL_API_KEY",
92
+ tier: "reader", batch: 1, what: "main-content markdown or html, boilerplate stripped; one URL per call" },
93
+ { 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",
94
+ method: "POST", auth: { header: "x-api-key" }, secret: "EXA_API_KEY",
95
+ tier: "extract", batch: 100, what: "text for up to 100 URLs in one call; always fetched fresh (maxAgeHours: 0), not Exa's cache" },
96
+ { 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",
97
+ method: "POST", auth: { header: "Authorization", prefix: "Bearer " }, secret: "TAVILY_API_KEY",
98
+ tier: "extract", batch: 20, what: "markdown or text for up to 20 URLs in one call, failures listed beside successes" },
99
+ { 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",
100
+ method: "POST", auth: { header: "x-api-key" }, secret: "PARALLEL_API_KEY",
101
+ tier: "extract", batch: 20, what: "full-page markdown for several URLs at once; handles JavaScript pages and PDFs" },
102
+ { 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",
103
+ method: "POST", auth: { header: "Authorization", prefix: "Basic " }, secret: "ZYTE_API_KEY_BASIC",
104
+ 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`",
105
+ 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" },
106
+ { 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/",
107
+ method: "GET", auth: { header: "Authorization", prefix: "Bearer " }, secret: "SCRAPINGBEE_API_KEY",
108
+ 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" },
109
+ ];
110
+ export const BROWSER_APIS = [
111
+ { 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",
112
+ 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" },
113
+ what: "hosted Chromium with stealth; POST /v1/sessions (X-BB-API-Key) returns connectUrl" },
114
+ { 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",
115
+ 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" },
116
+ what: "hosted Chromium, open-source core; POST /v1/sessions (steel-api-key), then wss://connect.steel.dev?apiKey&sessionId" },
117
+ { 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",
118
+ 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" },
119
+ what: "hosted Chromium with built-in unblocking; a session returns its wsEndpoint" },
120
+ { 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",
121
+ 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" },
122
+ what: "hosted Chrome; wss://production-sfo.browserless.io?token=… (other regions by name)",
123
+ note: "SSPL-licensed: the free path is out for a paid service; the cloud is a plain vendor." },
124
+ { 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",
125
+ session: { proxy: { always: true, country: true }, captcha: true, block: ["ads", "cookies"] },
126
+ secretFormat: "the zone credentials as `brd-customer-<id>-zone-<zone>:<password>` — the whole user:pass, which goes into the websocket URL",
127
+ what: "Chromium behind a residential proxy pool, port 9222 — the one worth paying for when a site refuses everything else" },
128
+ { 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",
129
+ secretFormat: "the ws://, wss:// or http(s):// DevTools address, token included where the browser needs one",
130
+ what: "any browser that serves the DevTools protocol — a Chrome started with --remote-debugging-port, a self-hosted pool, a vendor not listed here" },
131
+ { 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",
132
+ session: { proxy: { always: true, country: true }, timeout: [60, 900], options: "query", refuses: ["user_agent", "device"] },
133
+ what: "hosted Chromium with residential IPs and fingerprinting; wss://browser.zenrows.com?apikey=…" },
134
+ ];
135
+ /** The web's actions, in the order the docs list them. */
136
+ export const WEB_ACTIONS = ["search", "fetch", "browse", "crawl", "map", "extract", "answer", "monitor"];
137
+ export const ACTION_APIS = {
138
+ crawl: [
139
+ { 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" },
140
+ { 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" },
141
+ ],
142
+ map: [
143
+ { 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" },
144
+ { 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" },
145
+ ],
146
+ extract: [
147
+ { 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" },
148
+ { 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" },
149
+ { 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" },
150
+ ],
151
+ answer: [
152
+ { name: "exa", title: "Exa answer", host: "api.exa.ai", secret: "EXA_API_KEY", docs: "https://exa.ai/docs/reference/answer", checked: "2026-09-29" },
153
+ { 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" },
154
+ { 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" },
155
+ { 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" },
156
+ { 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" },
157
+ { 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" },
158
+ ],
159
+ monitor: [
160
+ { 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" },
161
+ { 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" },
162
+ ],
163
+ };
164
+ export function findActionApi(action, name) {
165
+ const key = name.trim().toLowerCase();
166
+ return ACTION_APIS[action].find((a) => a.name === key);
167
+ }
168
+ export function findBrowserApi(name) {
169
+ const key = name.trim().toLowerCase();
170
+ return BROWSER_APIS.find((a) => a.name === key || a.aliases?.includes(key));
171
+ }
172
+ export function findFetchApi(name) {
173
+ const key = name.trim().toLowerCase();
174
+ return FETCH_APIS.find((a) => a.name === key || a.aliases?.includes(key));
175
+ }
176
+ export function findSearchApi(name) {
177
+ const key = name.trim().toLowerCase();
178
+ return SEARCH_APIS.find((a) => a.name === key || a.aliases?.includes(key));
179
+ }
180
+ /** Whose index answers, for the ones that will answer at all. Checked against
181
+ * each vendor's own documentation on 2026-09-16. Worth stating out loud
182
+ * because it is the whole reason to prefer a provider's search over ours:
183
+ * you are buying an index we cannot crawl, not a faster endpoint. */
184
+ export const SEARCH_INDEX = {
185
+ anthropic: "Brave",
186
+ zai: "Zhipu's own (China-weighted)",
187
+ openrouter: "native where the model supports it, Exa otherwise",
188
+ openai: "Bing, plus OpenAI's own OAI-SearchBot crawl",
189
+ kimi: "Moonshot's own (China-weighted)",
190
+ };
32
191
  export const PROVIDERS = [
33
192
  // ------------------------------------------------ Anthropic-shaped, direct
34
193
  { name: "anthropic", title: "Anthropic", format: "anthropic", baseUrl: "https://api.anthropic.com", auth: "x-api-key", verified: true,
35
- note: "Models newer than Opus 4.6 reject top_k with a 400; the runtime never sends it unless a params: block does." },
194
+ 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" },
36
195
  { name: "openrouter", title: "OpenRouter", format: "anthropic", baseUrl: "https://openrouter.ai/api", auth: "bearer", verified: true,
37
- 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." },
196
+ 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" },
38
197
  { name: "deepseek", title: "DeepSeek", format: "anthropic", baseUrl: "https://api.deepseek.com/anthropic", auth: "x-api-key",
39
198
  note: "Ignores top_k, cache_control and thinking budgets; Claude model names are remapped to DeepSeek's." },
40
199
  { name: "kimi", title: "Moonshot Kimi", format: "anthropic", baseUrl: "https://api.moonshot.ai/anthropic", auth: "bearer",
41
- note: "Own model ids only. Known bug (2026-09): K3 reuses one tool_use id across separate calls, which breaks a tool loop." },
200
+ 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" },
42
201
  { name: "moonshot", title: "Moonshot Kimi", format: "anthropic", baseUrl: "https://api.moonshot.ai/anthropic", auth: "bearer",
43
- note: "Same endpoint as kimi." },
44
- { name: "zai", title: "z.ai (GLM)", format: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", auth: "bearer" },
202
+ note: "Same endpoint as kimi.", search: "builtin_fn" },
203
+ { name: "zai", title: "z.ai (GLM)", format: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", auth: "bearer", search: "anthropic" },
45
204
  { name: "minimax", title: "MiniMax", format: "anthropic", baseUrl: "https://api.minimax.io/anthropic", auth: "bearer" },
46
205
  { name: "qwen", title: "Alibaba Qwen (Model Studio)", format: "anthropic", auth: "x-api-key",
47
206
  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." },
@@ -64,7 +223,7 @@ export const PROVIDERS = [
64
223
  // ------------------------------------------- Chat-Completions, translated
65
224
  { name: "openai", title: "OpenAI", format: "openai", baseUrl: "https://api.openai.com/v1", auth: "bearer", verified: true,
66
225
  maxTokensParam: "max_completion_tokens", reasoningEffort: true,
67
- 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." },
226
+ 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" },
68
227
  { name: "gemini", title: "Google Gemini", format: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", auth: "bearer",
69
228
  note: "Google's OpenAI-compatible route. Unknown parameters are ignored silently; reasoning cannot be switched off on the newest models." },
70
229
  { name: "xai", title: "xAI Grok", format: "openai", baseUrl: "https://api.x.ai/v1", auth: "bearer",
@@ -112,3 +271,943 @@ export function providerPreset(name) {
112
271
  export function looksOpenAiShaped(baseUrl) {
113
272
  return /\/v1\/?$|\/chat\/completions|api\.openai\.com|\/openai\b/i.test(baseUrl);
114
273
  }
274
+ /** Every action web browse takes, which is what `deny:` may name. The
275
+ * gallery test holds this equal to the tool's own list, so a new action
276
+ * cannot be undeniable, and a misspelt denial — which would deny nothing —
277
+ * is refused at check time instead. */
278
+ export const WEB_BROWSE_ACTIONS = [
279
+ "click", "dblclick", "rightclick", "hover", "check", "uncheck", "drag", "fill", "type", "press", "select",
280
+ "wait", "goto", "screenshot", "pdf", "scroll", "paste", "upload", "download", "extract", "frame", "tab",
281
+ "back", "forward", "reload", "focus", "clear", "highlight", "keyboard", "keydown", "keyup", "mouse", "wheel",
282
+ "tap", "swipe", "clipboard", "eval", "get", "expect", "if", "mock", "unmock", "offline", "cookie",
283
+ "localstorage", "sessionstorage", "viewport", "dialog", "solve",
284
+ "pushstate", "insert", "webmcp", "login",
285
+ ];
286
+ // What a person writes, and what Playwright calls it. "chromium" and "webkit"
287
+ // are engine names; "chrome" and "safari" are what everyone else calls those
288
+ // browsers, and a file should read the way the room talks. Both spellings are
289
+ // accepted forever: agents written before this keep working, and the engine
290
+ // names remain the truth underneath (chrome here is the open-source Chromium
291
+ // build, not the branded Chrome; safari is WebKit, Safari's engine).
292
+ const BROWSE_ENGINE_ALIASES = {
293
+ // `chrome` is the branded Google Chrome (Playwright channel "chrome"), where
294
+ // the image has it; `chromium` is the open-source build. Same engine, a
295
+ // truer user-agent and codecs. Both still fall back to chromium if Chrome
296
+ // is not installed (a laptop without it, or an arch Google does not ship).
297
+ chrome: "chrome",
298
+ chromium: "chromium",
299
+ firefox: "firefox",
300
+ safari: "webkit",
301
+ webkit: "webkit",
302
+ // A browser that runs the JavaScript and never draws: a fraction of
303
+ // Chromium's memory, and no screenshots.
304
+ lightpanda: "lightpanda",
305
+ // A headless browser in Rust around V8 that does draw (screenshots, a
306
+ // raster PDF), light like Lightpanda; no request interception.
307
+ obscura: "obscura",
308
+ };
309
+ // Six engines, chromium the default. The list a refusal prints, so a person
310
+ // sees every spelling that works (webkit is safari's engine name and is
311
+ // accepted too, as an alias, not a seventh browser).
312
+ export const BROWSE_ENGINES = ["chromium", "chrome", "firefox", "safari", "lightpanda", "obscura"];
313
+ /**
314
+ * What each engine cannot do, and the words a refusal uses. ONE table: the
315
+ * tool (web/browse.mjs in the platform gallery) carries a copy that refuses a
316
+ * call, and `foldrun check` reads this one to refuse a `web.browse:` block
317
+ * that pairs an engine with something it cannot do — so the mistake is found
318
+ * at check time, not at 3am. The gallery test holds the tool's copy equal to
319
+ * this one, so neither can learn an option the other does not.
320
+ *
321
+ * An entry is what the tool prints for the option: a call argument (video,
322
+ * trace, block…), `mode=<mode>`, or `headless: false`. A refusal reads
323
+ * `<entry> <why>`. chromium and chrome can do all of it, so they have no row.
324
+ */
325
+ export const BROWSE_ENGINE_LIMITS = {
326
+ // Lightpanda never draws: no pixels, so nothing built on them.
327
+ lightpanda: {
328
+ why: "needs a browser that renders; lightpanda reads and drives pages without drawing them — use engine chrome",
329
+ cannot: [
330
+ "mode=screenshot", "mode=pdf", "mode=vitals", "mode=webmcp", "pdf", "webmcp", "video", "trace", "har", "device", "live", "session",
331
+ "webgpu", "extensions", "annotate", "visual", "cpu_profile", "proxy", "headless: false",
332
+ ],
333
+ },
334
+ // Obscura draws — screenshots, a raster pdf — but cannot intercept a
335
+ // request (a route hangs its page loads), record, or keep a profile — so nothing built on those, which
336
+ // includes the allowed_domains lock: a lock that did nothing would be
337
+ // worse than a refusal.
338
+ obscura: {
339
+ why: "is not something obscura can do (it has no request interception, recording or profiles) — use engine chrome",
340
+ cannot: [
341
+ "mode=webmcp", "webmcp", "video", "trace", "har", "device", "live", "session", "webgpu", "extensions", "cpu_profile",
342
+ "block", "routes", "mock", "unmock", "proxy", "captcha", "allowed_domains", "headless: false",
343
+ ],
344
+ },
345
+ firefox: { why: "is a Chromium feature — use engine chrome or chromium", cannot: ["mode=pdf", "mode=webmcp", "pdf", "webmcp", "live", "webgpu", "extensions", "cpu_profile"] },
346
+ webkit: { why: "is a Chromium feature — use engine chrome or chromium", cannot: ["mode=pdf", "mode=webmcp", "pdf", "webmcp", "live", "webgpu", "extensions", "cpu_profile"] },
347
+ };
348
+ /** The refusal for one option on one engine, or null when the engine can. */
349
+ export function browseEngineRefusal(engine, what) {
350
+ const row = BROWSE_ENGINE_LIMITS[engine];
351
+ return row && row.cannot.includes(what) ? `${what} ${row.why}` : null;
352
+ }
353
+ const BROWSE_SETTING_KEYS = [
354
+ "engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "identities", "headless", "version", "live",
355
+ "allowed_domains", "deny", "boundaries", "init", "extensions", "webgpu", "ignore_https_errors", "state_key",
356
+ "video", "live_view", "vendor_session",
357
+ // Deprecated spelling of vendor_session (read, and warned about).
358
+ "session",
359
+ ];
360
+ const BROWSE_DOMAIN = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
361
+ /** A list setting: YAML's list, or one comma-separated line. */
362
+ function browseList(v) {
363
+ if (Array.isArray(v))
364
+ return v.every((x) => typeof x === "string") ? v.map((x) => x.trim()).filter(Boolean) : null;
365
+ if (typeof v === "string")
366
+ return v.split(",").map((x) => x.trim()).filter(Boolean);
367
+ return null;
368
+ }
369
+ // What one identity may carry: the block's own settings, and the call
370
+ // arguments that describe who the browser is rather than what one call does.
371
+ const IDENTITY_KEYS = ["engine", "user_agent", "device", "locale", "timezone", "cookies", "cookie_domain", "storage", "storage_origin", "proxy", "headers", "geolocation", "permissions", "color_scheme", "block"];
372
+ const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
373
+ /** One named identity, checked the way the block is: text values, an
374
+ * engine that exists, secrets by NAME, a cookie with its domain. */
375
+ function readIdentity(name, raw) {
376
+ const where = `web.browse.identities.${name}`;
377
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(name))
378
+ return { error: `web.browse.identities: "${name}" is not a plain name (letters, digits, dot, dash, underscore).` };
379
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
380
+ return { error: `${where} must be a block of settings, like { device: "Pixel 7", locale: en-AU }.` };
381
+ const out = {};
382
+ for (const [k, v] of Object.entries(raw)) {
383
+ if (!IDENTITY_KEYS.includes(k))
384
+ return { error: `${where}.${k} is not an identity setting — they are ${IDENTITY_KEYS.join(", ")}.` };
385
+ if (v === undefined || v === null || v === "")
386
+ continue;
387
+ if (k === "headers") {
388
+ if (!v || typeof v !== "object" || Array.isArray(v))
389
+ return { error: `${where}.headers must be a map of header to value.` };
390
+ out.headers = JSON.stringify(v);
391
+ continue;
392
+ }
393
+ if (typeof v !== "string")
394
+ return { error: `${where}.${k} must be text, not ${Array.isArray(v) ? "a list" : typeof v}.` };
395
+ if (k === "engine") {
396
+ const canonical = BROWSE_ENGINE_ALIASES[v.toLowerCase()];
397
+ if (!canonical)
398
+ return { error: `${where}.engine: ${v} — the browsers are ${BROWSE_ENGINES.join(", ")}.` };
399
+ out.engine = canonical;
400
+ continue;
401
+ }
402
+ if ((k === "cookies" || k === "storage" || k === "proxy") && !SECRET_NAME.test(v)) {
403
+ return { error: `${where}.${k} must be the NAME of a vault secret (CAPITALS), never the value — store it with \`foldrun secrets set NAME\`.` };
404
+ }
405
+ out[k] = v;
406
+ }
407
+ if (out.cookies && !out.cookie_domain)
408
+ return { error: `${where}.cookies needs cookie_domain beside it — write \`cookie_domain: .example.com\`.` };
409
+ if (out.storage && !out.storage_origin)
410
+ return { error: `${where}.storage needs storage_origin beside it — write \`storage_origin: https://www.example.com\`.` };
411
+ return { identity: out };
412
+ }
413
+ /** The settings in a `web.browse:` block, and the vendor part with them
414
+ * removed — so one key carries both without either learning about the
415
+ * other. A string, or a block with no settings, gives no settings at all. */
416
+ const SESSION_KEYS = ["proxy", "captcha", "stealth", "region", "timeout", "keep", "record", "block", "options"];
417
+ const BLOCKABLE = ["ads", "trackers", "cookies"];
418
+ /** A duration as a person writes one — 90s, 30m, 2h, 1d, or a bare number
419
+ * of seconds — in seconds; null when it is none of those. */
420
+ function seconds(v) {
421
+ if (typeof v === "number" && Number.isFinite(v) && v > 0)
422
+ return Math.round(v);
423
+ const m = typeof v === "string" ? /^\s*(\d+(?:\.\d+)?)\s*(s|m|h|d)?\s*$/i.exec(v) : null;
424
+ if (!m)
425
+ return null;
426
+ return Math.round(Number(m[1]) * { s: 1, m: 60, h: 3600, d: 86400 }[(m[2] ?? "s").toLowerCase()]);
427
+ }
428
+ /** The shape of `web.browse.vendor_session:`, before anyone knows the vendor. What
429
+ * the vendor can do is browseSessionProblems' question. */
430
+ export function readBrowseSession(raw, key = "vendor_session") {
431
+ const at = `web.browse.${key}`;
432
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
433
+ return { error: `${at} is a block — what the vendor's own session is asked for: proxy, captcha, stealth, region, timeout, keep, record, block, options.` };
434
+ }
435
+ const out = {};
436
+ const bool = (k, v) => (v === true || v === "true" ? true : v === false || v === "false" ? false : undefined);
437
+ for (const [k, v] of Object.entries(raw)) {
438
+ if (!SESSION_KEYS.includes(k))
439
+ return { error: `${at}.${k}: is not a session setting. They are ${SESSION_KEYS.join(", ")}.` };
440
+ if (v === undefined || v === null || v === "")
441
+ continue;
442
+ if (k === "captcha" || k === "stealth" || k === "record") {
443
+ const b = bool(k, v);
444
+ if (b === undefined)
445
+ return { error: `${at}.${k} is true or false, not ${JSON.stringify(v)}.` };
446
+ out[k] = b;
447
+ }
448
+ else if (k === "proxy") {
449
+ const b = bool(k, v);
450
+ if (b !== undefined) {
451
+ out.proxy = b;
452
+ continue;
453
+ }
454
+ if (typeof v !== "object" || Array.isArray(v))
455
+ return { error: `${at}.proxy is true, false, or a block: country, state, city — or own: a secret holding your proxy's URL.` };
456
+ const p = {};
457
+ for (const [pk, pv] of Object.entries(v)) {
458
+ if (pk === "country" || pk === "state") {
459
+ if (typeof pv !== "string" || !/^[A-Za-z]{2}$/.test(pv.trim()))
460
+ return { error: `${at}.proxy.${pk} is a two-letter code, like ${pk === "country" ? "AU" : "CA"} — not ${JSON.stringify(pv)}.` };
461
+ p[pk] = pv.trim().toUpperCase();
462
+ }
463
+ else if (pk === "city") {
464
+ if (typeof pv !== "string" || !pv.trim())
465
+ return { error: `${at}.proxy.city is a city's name.` };
466
+ p.city = pv.trim();
467
+ }
468
+ else if (pk === "own") {
469
+ if (typeof pv !== "string" || !SECRET_NAME.test(pv.trim()))
470
+ return { error: `${at}.proxy.own names a secret holding your proxy's URL (http://user:pass@host:port) — the NAME, never the URL.` };
471
+ p.own = pv.trim();
472
+ }
473
+ else {
474
+ return { error: `${at}.proxy.${pk}: is not a proxy setting. They are country, state, city, own.` };
475
+ }
476
+ }
477
+ if (p.own && (p.country || p.state || p.city))
478
+ return { error: `${at}.proxy: own is your proxy, so its location is yours to set there — drop country, state and city, or own.` };
479
+ if (p.state && p.country && p.country !== "US")
480
+ return { error: `${at}.proxy.state is for the US only.` };
481
+ out.proxy = p;
482
+ }
483
+ else if (k === "region" || k === "keep") {
484
+ if (typeof v !== "string" || !v.trim())
485
+ return { error: `${at}.${k} is a name.` };
486
+ if (k === "keep" && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(v.trim()))
487
+ return { error: `${at}.keep is a short name in lower case, like client-portal — the saved login it keeps.` };
488
+ out[k] = v.trim();
489
+ }
490
+ else if (k === "timeout") {
491
+ const n = seconds(v);
492
+ if (n === null)
493
+ return { error: `${at}.timeout is a duration, like 90s, 30m or 2h — not ${JSON.stringify(v)}.` };
494
+ out.timeout = n;
495
+ }
496
+ else if (k === "block") {
497
+ const list = browseList(v);
498
+ const bad = list?.find((x) => !BLOCKABLE.includes(x));
499
+ if (!list || bad !== undefined)
500
+ return { error: `${at}.block is a list of ${BLOCKABLE.join(", ")}${bad ? ` — not ${bad}` : ""}.` };
501
+ out.block = [...new Set(list)];
502
+ }
503
+ else {
504
+ if (typeof v !== "object" || Array.isArray(v))
505
+ return { error: `${at}.options is a block, passed to the vendor as written.` };
506
+ out.options = v;
507
+ }
508
+ }
509
+ return { session: out };
510
+ }
511
+ /**
512
+ * Everything wrong with a session block for this vendor: a setting the
513
+ * vendor has no option for (naming the vendors that do), a value outside its
514
+ * range, and the browse settings its browser will not take. The rule the
515
+ * actions follow — the file is fixed, nothing is quietly dropped.
516
+ */
517
+ export function browseSessionProblems(settings, vendor) {
518
+ const s = settings.vendor_session;
519
+ const out = [];
520
+ const api = vendor ? findBrowserApi(vendor) : undefined;
521
+ const sup = api?.session ?? {};
522
+ const who = (pred) => BROWSER_APIS.filter((b) => b.session && pred(b.session)).map((b) => b.name).join(", ") || "none";
523
+ const at = "web.browse.vendor_session";
524
+ for (const key of api?.session?.refuses ?? []) {
525
+ if (settings[key] !== undefined) {
526
+ out.push(`web.browse.${key}: ${api.title} does not let a session change it (its docs) — drop it, or render elsewhere.`);
527
+ }
528
+ }
529
+ if (!s)
530
+ return out;
531
+ if (!api) {
532
+ 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:.`);
533
+ return out;
534
+ }
535
+ const label = api.title;
536
+ const need = (key, ok, pred) => {
537
+ if (!ok)
538
+ out.push(`${at}.${key}: ${label} has no such option. These do: ${who(pred)}.`);
539
+ };
540
+ if (s.proxy !== undefined) {
541
+ const p = sup.proxy;
542
+ if (s.proxy === false && p?.always)
543
+ out.push(`${at}.proxy: ${label} always goes through its own proxy network — false cannot be kept.`);
544
+ else if (s.proxy === true)
545
+ need("proxy", p?.on || p?.always, (x) => x.proxy?.on || x.proxy?.always);
546
+ else if (typeof s.proxy === "object") {
547
+ for (const part of ["country", "state", "city", "own"]) {
548
+ if (s.proxy[part] !== undefined)
549
+ need(`proxy.${part}`, p?.[part], (x) => x.proxy?.[part]);
550
+ }
551
+ if (p?.needsCountry && !s.proxy.own && !s.proxy.country)
552
+ out.push(`${at}.proxy: ${label} needs a country for a proxy location (its docs) — add country:.`);
553
+ if (p?.stateOrCity && s.proxy.state && s.proxy.city)
554
+ out.push(`${at}.proxy: ${label} takes a state or a city, not both (its docs).`);
555
+ }
556
+ }
557
+ if (s.captcha !== undefined)
558
+ need("captcha", sup.captcha, (x) => x.captcha);
559
+ if (s.stealth !== undefined)
560
+ need("stealth", sup.stealth, (x) => x.stealth);
561
+ if (s.record !== undefined)
562
+ need("record", sup.record, (x) => x.record);
563
+ if (s.keep !== undefined) {
564
+ need("keep", sup.keep, (x) => x.keep);
565
+ // A saved login lives in the vendor's own browser context, which is
566
+ // used as it is: a context made to measure would not be the saved one.
567
+ for (const key of ["user_agent", "device", "locale", "timezone", "identities"]) {
568
+ if (settings[key] !== undefined) {
569
+ 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.`);
570
+ }
571
+ }
572
+ }
573
+ if (s.region !== undefined) {
574
+ if (!sup.region)
575
+ need("region", false, (x) => Boolean(x.region));
576
+ else if (!sup.region.includes(s.region))
577
+ out.push(`${at}.region: ${label}'s regions are ${sup.region.join(", ")} — not ${s.region}.`);
578
+ }
579
+ if (s.timeout !== undefined) {
580
+ if (!sup.timeout)
581
+ need("timeout", false, (x) => Boolean(x.timeout));
582
+ else if (s.timeout < sup.timeout[0] || s.timeout > sup.timeout[1]) {
583
+ out.push(`${at}.timeout: ${label} takes ${sup.timeout[0]}s to ${sup.timeout[1]}s, not ${s.timeout}s.`);
584
+ }
585
+ }
586
+ for (const b of s.block ?? []) {
587
+ if (!sup.block?.includes(b))
588
+ out.push(`${at}.block: ${label} cannot block ${b}. These can: ${who((x) => x.block?.includes(b))}.`);
589
+ }
590
+ if (s.options !== undefined && !sup.options)
591
+ out.push(`${at}.options: ${label} takes no options beyond these.`);
592
+ return out;
593
+ }
594
+ export function readBrowseSettings(raw) {
595
+ // An empty leftover is nothing at all. Returned as `{}` it reads to the
596
+ // vendor resolver as "the long form, with no name", which is a second
597
+ // error about a key the person never wrote.
598
+ const left = (o) => (Object.keys(o).length ? o : undefined);
599
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
600
+ return { settings: {}, rest: raw };
601
+ const o = raw;
602
+ const settings = {};
603
+ const rest = {};
604
+ for (const [k, v] of Object.entries(o)) {
605
+ if (!BROWSE_SETTING_KEYS.includes(k)) {
606
+ rest[k] = v;
607
+ continue;
608
+ }
609
+ if (v === undefined || v === null || v === "")
610
+ continue;
611
+ if (k === "identities") {
612
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
613
+ 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 } }." };
614
+ }
615
+ const identities = {};
616
+ for (const [name, block] of Object.entries(v)) {
617
+ const read = readIdentity(name, block);
618
+ if (read.error)
619
+ return { settings, rest: left(rest), error: read.error };
620
+ identities[name] = read.identity;
621
+ }
622
+ if (Object.keys(identities).length)
623
+ settings.identities = identities;
624
+ continue;
625
+ }
626
+ // The one setting that is a yes or no. YAML gives a boolean; a quoted
627
+ // "false" is the same wish and is read as one.
628
+ if (k === "headless") {
629
+ const b = v === true || v === "true" ? true : v === false || v === "false" ? false : undefined;
630
+ if (b === undefined)
631
+ return { settings, rest: left(rest), error: `web.browse.headless is true or false, not ${JSON.stringify(v)}.` };
632
+ settings.headless = b;
633
+ continue;
634
+ }
635
+ if (k === "live" || k === "boundaries" || k === "webgpu" || k === "ignore_https_errors" || k === "video") {
636
+ const b = v === true || v === "true" ? true : v === false || v === "false" ? false : undefined;
637
+ if (b === undefined)
638
+ return { settings, rest: left(rest), error: `web.browse.${k} is true or false, not ${JSON.stringify(v)}.` };
639
+ if (b)
640
+ settings[k] = true;
641
+ continue;
642
+ }
643
+ // The vendor's own session. `session:` is the old spelling: a call's
644
+ // `session=` is a named saved login, a different thing under the same
645
+ // word, so the block key became vendor_session. Both read; one only.
646
+ if (k === "vendor_session" || k === "session") {
647
+ if (o.vendor_session !== undefined && o.session !== undefined && o.vendor_session !== null && o.session !== null) {
648
+ 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." };
649
+ }
650
+ const r = readBrowseSession(v, k);
651
+ if (r.error)
652
+ return { settings, rest: left(rest), error: r.error };
653
+ settings.vendor_session = r.session;
654
+ continue;
655
+ }
656
+ // The one that is on by default: only false travels.
657
+ if (k === "live_view") {
658
+ const b = v === true || v === "true" ? true : v === false || v === "false" ? false : undefined;
659
+ if (b === undefined)
660
+ return { settings, rest: left(rest), error: `web.browse.live_view is true or false, not ${JSON.stringify(v)}.` };
661
+ if (!b)
662
+ settings.live_view = false;
663
+ continue;
664
+ }
665
+ if (k === "allowed_domains") {
666
+ const list = browseList(v)?.map((d) => d.toLowerCase());
667
+ if (!list)
668
+ return { settings, rest: left(rest), error: "web.browse.allowed_domains is a list of domains, like [example.com, \"*.example.com\"]." };
669
+ const bad = list.find((d) => !BROWSE_DOMAIN.test(d));
670
+ if (bad)
671
+ return { settings, rest: left(rest), error: `web.browse.allowed_domains: ${bad} is not a domain or *.domain — no scheme, no path.` };
672
+ if (list.length)
673
+ settings.allowed_domains = list;
674
+ continue;
675
+ }
676
+ if (k === "deny") {
677
+ const list = browseList(v)?.map((d) => d.toLowerCase());
678
+ if (!list)
679
+ return { settings, rest: left(rest), error: "web.browse.deny is a list of actions, like [eval, download]." };
680
+ const known = new Set([...WEB_BROWSE_ACTIONS, "js"]);
681
+ const bad = list.find((a) => !known.has(a));
682
+ if (bad)
683
+ 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(", ")}.` };
684
+ if (list.length)
685
+ settings.deny = list;
686
+ continue;
687
+ }
688
+ if (k === "init" || k === "extensions") {
689
+ const list = browseList(v);
690
+ if (!list)
691
+ 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"}].` };
692
+ const bad = list.find((p) => p.startsWith("/") || p.split(/[\\/]/).includes("..") || p.includes(","));
693
+ if (bad)
694
+ return { settings, rest: left(rest), error: `web.browse.${k}: ${bad} must be a path inside the workspace (no leading /, no .., no comma).` };
695
+ if (list.length)
696
+ settings[k] = list;
697
+ continue;
698
+ }
699
+ if (k === "state_key") {
700
+ if (typeof v !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(v))
701
+ 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:." };
702
+ settings.state_key = v;
703
+ continue;
704
+ }
705
+ if (k === "version") {
706
+ const val = String(v).trim();
707
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(val))
708
+ 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).` };
709
+ settings.version = val;
710
+ continue;
711
+ }
712
+ if (typeof v !== "string") {
713
+ return { settings, rest: left(rest), error: `web.browse.${k} must be text, not ${Array.isArray(v) ? "a list" : typeof v}.` };
714
+ }
715
+ if (k === "engine") {
716
+ const canonical = BROWSE_ENGINE_ALIASES[v.toLowerCase()];
717
+ if (!canonical) {
718
+ return { settings, rest: left(rest), error: `web.browse.engine: ${v} — the browsers are ${BROWSE_ENGINES.join(", ")}.` };
719
+ }
720
+ settings.engine = canonical;
721
+ continue;
722
+ }
723
+ // A vault name, not a cookie. Someone will eventually paste the header
724
+ // line straight into the file; it is refused here, where the mistake is
725
+ // still private, rather than committed and read by everyone with the repo.
726
+ if (k === "storage" && !/^[A-Z][A-Z0-9_]*$/.test(v)) {
727
+ return {
728
+ settings,
729
+ rest: left(rest),
730
+ error: `web.browse.storage must be the NAME of a vault secret (CAPITALS), not the storage itself — ` +
731
+ `store the JSON with \`foldrun secrets set NAME\` and write \`storage: NAME\`.`,
732
+ };
733
+ }
734
+ // An origin, not a cookie domain: Web Storage and IndexedDB are walled off
735
+ // per origin, scheme and host together, so `.example.com` cannot be seeded.
736
+ if (k === "storage_origin" && !/^https?:\/\/[^/\s]+$/.test(v)) {
737
+ return {
738
+ settings,
739
+ rest: left(rest),
740
+ error: `web.browse.storage_origin must be an origin, scheme and host with no path — ` +
741
+ `write \`storage_origin: https://www.example.com\`, not \`.example.com\`.`,
742
+ };
743
+ }
744
+ if (k === "cookies" && !/^[A-Z][A-Z0-9_]*$/.test(v)) {
745
+ return {
746
+ settings,
747
+ rest: left(rest),
748
+ error: `web.browse.cookies must be the NAME of a vault secret (CAPITALS), not the cookies themselves — ` +
749
+ `store them with \`foldrun secrets set NAME\` and write \`cookies: NAME\`.`,
750
+ };
751
+ }
752
+ settings[k] = v;
753
+ }
754
+ // A cookie default without a domain would ride on whatever host the call
755
+ // opens, which is one agent handing a site someone else's session. The
756
+ // domain is what keeps a file-level cookie to the site it belongs to.
757
+ if (settings.cookies && !settings.cookie_domain) {
758
+ return {
759
+ settings,
760
+ rest: left(rest),
761
+ error: `web.browse.cookies needs web.browse.cookie_domain beside it — a cookie default with no domain ` +
762
+ `would be sent to whatever site the call opens. Write \`cookie_domain: .example.com\`.`,
763
+ };
764
+ }
765
+ // Storage is per origin, and seeding it into the wrong one would hand a
766
+ // site another site's signed-in state — the same mistake `cookie_domain`
767
+ // exists to prevent, one storage area over.
768
+ if (settings.storage && !settings.storage_origin) {
769
+ return {
770
+ settings,
771
+ rest: left(rest),
772
+ error: `web.browse.storage needs web.browse.storage_origin beside it — Web Storage and IndexedDB are ` +
773
+ `walled off per origin, so the tool has to be told which one. Write \`storage_origin: https://www.example.com\`.`,
774
+ };
775
+ }
776
+ // `via:` is the readable name for what used to be the whole value.
777
+ if (typeof rest.via === "string" && rest.name === undefined) {
778
+ rest.name = rest.via;
779
+ delete rest.via;
780
+ }
781
+ return { settings, rest: Object.keys(rest).length ? rest : undefined };
782
+ }
783
+ /** SearXNG's plugin ids (searx/plugins/*.py, 2026-09-29). The gallery test
784
+ * keeps this equal to the list the tool documents. */
785
+ export const WEB_SEARCH_PLUGINS = [
786
+ "ahmia_filter", "calculator", "hash_plugin", "hostnames", "infinite_scroll", "oa_doi_rewrite",
787
+ "self_info", "time_zone", "tor_check", "tracker_url_remover", "unit_converter",
788
+ ];
789
+ const SEARCH_SETTING_KEYS = [
790
+ "engines", "exclude_engines", "categories", "safesearch", "time_range", "timeout", "plugins", "exclude_plugins", "doi_resolver",
791
+ ];
792
+ // SearXNG names engines the way people do — "google cse", "wikicommons.images",
793
+ // "yandex api" — so a space and a dot are allowed. A comma is not: the tool
794
+ // sends the list comma-joined, and one inside a name would split it.
795
+ const SEARCH_ENGINE_NAME = /^[a-z0-9][a-z0-9 ._-]{0,63}$/i;
796
+ const SEARCH_CATEGORY_NAME = /^[a-z][a-z0-9 _-]{0,31}$/i;
797
+ const SAFESEARCH_WORDS = { off: 0, none: 0, moderate: 1, strict: 2 };
798
+ /** The settings in a `web.search:` block, and the provider part with them
799
+ * removed — the shape readBrowseSettings has, for the same reason. */
800
+ export function readSearchSettings(raw) {
801
+ const left = (o) => (Object.keys(o).length ? o : undefined);
802
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
803
+ return { settings: {}, rest: raw };
804
+ const settings = {};
805
+ const rest = {};
806
+ const fail = (error) => ({ settings, rest: left(rest), error });
807
+ for (const [k, v] of Object.entries(raw)) {
808
+ if (!SEARCH_SETTING_KEYS.includes(k)) {
809
+ rest[k] = v;
810
+ continue;
811
+ }
812
+ if (v === undefined || v === null || v === "")
813
+ continue;
814
+ if (k === "engines" || k === "exclude_engines") {
815
+ const list = browseList(v)?.map((e) => e.toLowerCase());
816
+ if (!list)
817
+ return fail(`web.search.${k} is a list of SearXNG engine names, like [bing, google cse, duckduckgo].`);
818
+ const bad = list.find((e) => !SEARCH_ENGINE_NAME.test(e));
819
+ if (bad !== undefined)
820
+ return fail(`web.search.${k}: ${JSON.stringify(bad)} is not an engine name — letters, digits, space, dot, dash.`);
821
+ if (list.length)
822
+ settings[k] = [...new Set(list)];
823
+ continue;
824
+ }
825
+ if (k === "categories") {
826
+ const list = browseList(v)?.map((c) => c.toLowerCase());
827
+ if (!list)
828
+ return fail("web.search.categories is a list of SearXNG categories, like [general, news].");
829
+ const bad = list.find((c) => !SEARCH_CATEGORY_NAME.test(c));
830
+ if (bad !== undefined)
831
+ return fail(`web.search.categories: ${JSON.stringify(bad)} is not a category name.`);
832
+ if (list.length)
833
+ settings.categories = [...new Set(list)];
834
+ continue;
835
+ }
836
+ if (k === "plugins" || k === "exclude_plugins") {
837
+ const list = browseList(v)?.map((p) => p.toLowerCase());
838
+ if (!list)
839
+ return fail(`web.search.${k} is a list of SearXNG plugin ids, like [oa_doi_rewrite].`);
840
+ // Matched without underscores: SearXNG has respelt ids between releases
841
+ // (infiniteScroll → infinite_scroll), and the tool sends whichever
842
+ // spelling the running instance uses.
843
+ const bare = (p) => p.replace(/_/g, "");
844
+ const bad = list.find((p) => !WEB_SEARCH_PLUGINS.some((id) => bare(id) === bare(p)));
845
+ if (bad !== undefined) {
846
+ return fail(`web.search.${k}: ${bad} is not a SearXNG plugin — a misspelt one would switch nothing. The plugins are ${WEB_SEARCH_PLUGINS.join(", ")}.`);
847
+ }
848
+ if (list.length)
849
+ settings[k] = [...new Set(list)];
850
+ continue;
851
+ }
852
+ if (k === "safesearch") {
853
+ const n = typeof v === "number" ? v : typeof v === "string" ? (SAFESEARCH_WORDS[v.trim().toLowerCase()] ?? (/^[012]$/.test(v.trim()) ? Number(v) : NaN)) : NaN;
854
+ if (n !== 0 && n !== 1 && n !== 2)
855
+ return fail(`web.search.safesearch is off, moderate or strict (or 0, 1, 2), not ${JSON.stringify(v)}.`);
856
+ settings.safesearch = n;
857
+ continue;
858
+ }
859
+ if (k === "time_range") {
860
+ const r = String(v).trim().toLowerCase();
861
+ if (r !== "day" && r !== "week" && r !== "month" && r !== "year")
862
+ return fail(`web.search.time_range is day, week, month or year, not ${JSON.stringify(v)}.`);
863
+ settings.time_range = r;
864
+ continue;
865
+ }
866
+ if (k === "timeout") {
867
+ const n = typeof v === "number" ? v : Number(String(v).replace(/s$/, ""));
868
+ if (!Number.isFinite(n) || n < 0.5 || n > 30)
869
+ return fail(`web.search.timeout is seconds, 0.5 to 30, not ${JSON.stringify(v)}.`);
870
+ settings.timeout = n;
871
+ continue;
872
+ }
873
+ // doi_resolver: a host SearXNG knows (oadoi.org, doi.org, …), checked
874
+ // against the instance by the tool; here only that it is a host.
875
+ if (typeof v !== "string" || !BROWSE_DOMAIN.test(v.trim().toLowerCase())) {
876
+ return fail(`web.search.doi_resolver is a resolver's host, like oadoi.org or doi.org, not ${JSON.stringify(v)}.`);
877
+ }
878
+ settings.doi_resolver = v.trim().toLowerCase();
879
+ }
880
+ const both = (settings.engines ?? []).find((e) => settings.exclude_engines?.includes(e));
881
+ if (both)
882
+ return fail(`web.search: ${both} is in both engines and exclude_engines — say which.`);
883
+ const bothP = (settings.plugins ?? []).find((p) => settings.exclude_plugins?.includes(p));
884
+ if (bothP)
885
+ return fail(`web.search: ${bothP} is in both plugins and exclude_plugins — say which.`);
886
+ if (Object.keys(settings).length && (rest.name !== undefined || rest.key !== undefined)) {
887
+ return fail(`web.search: ${Object.keys(settings).join(", ")} ${Object.keys(settings).length === 1 ? "is a setting" : "are settings"} for the account's own engine (SearXNG); ` +
888
+ `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)}.`);
889
+ }
890
+ return { settings, rest: left(rest) };
891
+ }
892
+ /** The `web.search:` block's settings as the env the tool reads. Only what
893
+ * was said travels — an unset key is SearXNG's own default. */
894
+ export function searchSettingsEnv(s) {
895
+ return {
896
+ ...(s.engines ? { FOLDRUN_WEB_SEARCH_ENGINES: s.engines.join(",") } : {}),
897
+ ...(s.exclude_engines ? { FOLDRUN_WEB_SEARCH_EXCLUDE_ENGINES: s.exclude_engines.join(",") } : {}),
898
+ ...(s.categories ? { FOLDRUN_WEB_SEARCH_CATEGORIES: s.categories.join(",") } : {}),
899
+ ...(s.safesearch !== undefined ? { FOLDRUN_WEB_SEARCH_SAFESEARCH: String(s.safesearch) } : {}),
900
+ ...(s.time_range ? { FOLDRUN_WEB_SEARCH_TIME_RANGE: s.time_range } : {}),
901
+ ...(s.timeout !== undefined ? { FOLDRUN_WEB_SEARCH_TIMEOUT: String(s.timeout) } : {}),
902
+ ...(s.plugins ? { FOLDRUN_WEB_SEARCH_PLUGINS: s.plugins.join(",") } : {}),
903
+ ...(s.exclude_plugins ? { FOLDRUN_WEB_SEARCH_EXCLUDE_PLUGINS: s.exclude_plugins.join(",") } : {}),
904
+ ...(s.doi_resolver ? { FOLDRUN_WEB_SEARCH_DOI_RESOLVER: s.doi_resolver } : {}),
905
+ };
906
+ }
907
+ function readChoice(raw, field) {
908
+ if (typeof raw === "string")
909
+ return { name: raw };
910
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
911
+ const o = raw;
912
+ if (typeof o.name !== "string" || !o.name.trim()) {
913
+ return { error: `${field}: the long form needs \`name:\` — the API or provider to ask.` };
914
+ }
915
+ if (o.key === undefined)
916
+ return { name: o.name };
917
+ if (typeof o.key !== "string")
918
+ return { error: `${field}.key must be a \${NAME} reference to a secret in the vault.` };
919
+ const m = /^\$\{([A-Z][A-Z0-9_]*)\}$/.exec(o.key.trim());
920
+ if (!m) {
921
+ return {
922
+ error: `${field}.key must be a \${NAME} reference to a secret in the vault, not the key itself — ` +
923
+ `store it with \`foldrun secrets set NAME\` and write \`key: \${NAME}\`.`,
924
+ };
925
+ }
926
+ return { name: o.name, secret: m[1] };
927
+ }
928
+ return { error: `${field}: takes a name, or a block with name: and key:, or nothing at all for the runtime's own.` };
929
+ }
930
+ export function resolveSearch(name, kind = "search") {
931
+ const field = kind === "fetch" ? "web.fetch" : kind === "browse" ? "web.browse" : "web.search";
932
+ if (name === undefined || name === null || name === "" || name === "ours") {
933
+ return { provider: null };
934
+ }
935
+ const read = readChoice(name, field);
936
+ if ("error" in read)
937
+ return { provider: null, error: read.error };
938
+ const key = read.name.trim().toLowerCase();
939
+ if (key in REFUSED_WEB)
940
+ return { provider: null, error: `${field}: ${key} — ${REFUSED_WEB[key]}` };
941
+ if (kind === "browse") {
942
+ const b = findBrowserApi(key);
943
+ if (!b) {
944
+ 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.` };
945
+ }
946
+ return { provider: b.name, shape: "direct", index: b.what, secret: read.secret ?? b.secret, secretOptional: false, host: b.host };
947
+ }
948
+ const api = kind === "fetch" ? findFetchApi(key) : findSearchApi(key);
949
+ if (api) {
950
+ return {
951
+ provider: api.name,
952
+ shape: "direct",
953
+ index: "index" in api ? api.index : api.what,
954
+ secret: read.secret ?? api.secret,
955
+ // A custom vault name is a deliberate choice; it is never optional.
956
+ secretOptional: read.secret ? false : Boolean(api.secretOptional),
957
+ host: api.host,
958
+ };
959
+ }
960
+ if (kind === "fetch") {
961
+ const other = findSearchApi(key);
962
+ if (other) {
963
+ 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.` };
964
+ }
965
+ if (key === "anthropic")
966
+ return { provider: "anthropic", shape: "anthropic", index: "Anthropic's web_fetch — a small model's reading of the page, on Anthropic's servers" };
967
+ const preset = PROVIDERS.find((p) => p.name === key);
968
+ return {
969
+ provider: null,
970
+ error: preset
971
+ ? `web.fetch: ${key} — ${preset.title} has no fetch a tool can call. The fetch APIs: ${FETCH_APIS.map((a) => a.name).join(", ")}; or anthropic.`
972
+ : `web.fetch: ${key} — no fetch API or provider by that name. The fetch APIs: ${FETCH_APIS.map((a) => a.name).join(", ")}; or anthropic.`,
973
+ };
974
+ }
975
+ if (read.secret) {
976
+ 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.` };
977
+ }
978
+ const preset = PROVIDERS.find((p) => p.name === key);
979
+ if (!preset) {
980
+ const providers = PROVIDERS.filter((p) => p.search).map((p) => p.name).join(", ");
981
+ const apis = SEARCH_APIS.map((a) => a.name).join(", ");
982
+ 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}.` };
983
+ }
984
+ if (!preset.search) {
985
+ return {
986
+ provider: null,
987
+ error: `web.search: ${key} — ${preset.title} has no server-side search. ` +
988
+ `Its endpoint is ${preset.format}-shaped, but speaking a wire is not the same as running a tool on it. ` +
989
+ `Leave web.search: unset to use the account's own search engine.`,
990
+ };
991
+ }
992
+ return { provider: key, shape: preset.search, index: SEARCH_INDEX[key] };
993
+ }
994
+ /** A `web.browse:` block that pairs an engine with something it cannot do,
995
+ * in the tool's own words (BROWSE_ENGINE_LIMITS). The block's engine is
996
+ * checked against the block's settings; each identity is checked with its
997
+ * own engine (or the block's) against the block's settings and its own,
998
+ * because a call that picks the identity gets both. What only a call can
999
+ * ask for (a mode, a trace, mocks) is the tool's to refuse. */
1000
+ export function browseEngineProblems(settings) {
1001
+ const fromBlock = [];
1002
+ if (settings.video)
1003
+ fromBlock.push(["video", "video"]);
1004
+ if (settings.device)
1005
+ fromBlock.push(["device", "device"]);
1006
+ if (settings.live)
1007
+ fromBlock.push(["live", "live"]);
1008
+ if (settings.webgpu)
1009
+ fromBlock.push(["webgpu", "webgpu"]);
1010
+ if (settings.extensions?.length)
1011
+ fromBlock.push(["extensions", "extensions"]);
1012
+ if (settings.allowed_domains?.length)
1013
+ fromBlock.push(["allowed_domains", "allowed_domains"]);
1014
+ if (settings.headless === false)
1015
+ fromBlock.push(["headless", "headless: false"]);
1016
+ const out = [];
1017
+ const engine = settings.engine ?? "chromium";
1018
+ for (const [key, what] of fromBlock) {
1019
+ const no = browseEngineRefusal(engine, what);
1020
+ if (no)
1021
+ out.push(`web.browse.${key}: ${no}.`);
1022
+ }
1023
+ for (const [name, id] of Object.entries(settings.identities ?? {})) {
1024
+ const eng = id.engine ?? engine;
1025
+ const own = ["device", "proxy", "block"].filter((k) => id[k]).map((k) => [k, k]);
1026
+ for (const [key, what] of own) {
1027
+ const no = browseEngineRefusal(eng, what);
1028
+ if (no)
1029
+ out.push(`web.browse.identities.${name}.${key}: ${no}.`);
1030
+ }
1031
+ // The block's settings ride on every call, so an identity that changes
1032
+ // the engine must be able to do them too.
1033
+ if (id.engine && id.engine !== engine) {
1034
+ for (const [key, what] of fromBlock) {
1035
+ if (id[key])
1036
+ continue;
1037
+ const no = browseEngineRefusal(eng, what);
1038
+ if (no)
1039
+ out.push(`web.browse.identities.${name}: engine ${eng} with web.browse.${key} — ${no}.`);
1040
+ }
1041
+ }
1042
+ }
1043
+ return out;
1044
+ }
1045
+ /** Every `web:` value in a frontmatter that cannot work, each as the
1046
+ * sentence resolveSearch gives. Computed once in core so `check`, the
1047
+ * deploy gate and the run all refuse the same thing in the same words —
1048
+ * the timezone rule's shape. Empty when all are unset or answerable. */
1049
+ export function webProblems(front) {
1050
+ const web = webConfig(front);
1051
+ const out = [...web.problems];
1052
+ for (const kind of ["search", "fetch", "browse"]) {
1053
+ let value = web.raw[kind];
1054
+ let browse = null;
1055
+ if (kind === "browse" || kind === "search") {
1056
+ const read = kind === "browse" ? readBrowseSettings(value) : readSearchSettings(value);
1057
+ if (read.error) {
1058
+ out.push(read.error);
1059
+ continue;
1060
+ }
1061
+ if (kind === "browse") {
1062
+ browse = read.settings;
1063
+ out.push(...browseEngineProblems(browse));
1064
+ }
1065
+ value = read.rest;
1066
+ }
1067
+ const choice = resolveSearch(value, kind);
1068
+ if (choice.error)
1069
+ out.push(choice.error);
1070
+ else if (browse)
1071
+ out.push(...browseSessionProblems(browse, choice.provider));
1072
+ }
1073
+ return out;
1074
+ }
1075
+ /**
1076
+ * The engines the runner image was built with, as the deploy wrote them into
1077
+ * FOLDRUN_RUNNER_ENGINES (foldrun-infra dev/ci/runner-engines.sh): a comma
1078
+ * list of names, each with `=<version>` where the image's manifest
1079
+ * (/opt/browser/engines.json) has a dotted version —
1080
+ * `chromium=153.0.8010.12,chrome=154.0.8037.58,firefox,lightpanda=0.4.1`.
1081
+ * Null when unset.
1082
+ */
1083
+ export function runnerEngines(raw = process.env.FOLDRUN_RUNNER_ENGINES) {
1084
+ const out = {};
1085
+ for (const part of (raw ?? "").split(",")) {
1086
+ const m = /^\s*([a-z][a-z0-9-]{0,31})(?:=(\d+(?:\.\d+)+))?\s*$/.exec(part);
1087
+ if (m)
1088
+ out[m[1]] = m[2] ?? null;
1089
+ }
1090
+ return Object.keys(out).length ? out : null;
1091
+ }
1092
+ /** The Chrome major a user-agent string claims — `Chrome/153.0.0.0` → 153 —
1093
+ * or null for one that names no Chrome (Firefox, Safari, an iPhone). */
1094
+ export function chromeMajorOf(ua) {
1095
+ if (/\b(Firefox|FxiOS|CriOS|Edg)\//.test(ua))
1096
+ return null;
1097
+ const m = /Chrome\/(\d+)\./.exec(ua); // HeadlessChrome/ too
1098
+ return m ? Number(m[1]) : null;
1099
+ }
1100
+ /**
1101
+ * A pinned `user_agent:` that claims another Chrome than the one that will
1102
+ * send it. The tool's own default follows the installed Chrome (its
1103
+ * chromiumVersion()), so only a pinned string can drift — and it drifts
1104
+ * every time the image's Chrome moves (stable is unpinned, a new major every
1105
+ * four weeks). A site then sees `Chrome/153` in the User-Agent header beside
1106
+ * `"Google Chrome";v="154"` in the client hints the browser still sends, and
1107
+ * a JavaScript engine that is 154's: the mismatch bot checks look for.
1108
+ * `chrome` is the image's version of that browser (runnerEngines().chrome,
1109
+ * or .chromium for engine chromium); null — an image that did not say —
1110
+ * warns about nothing.
1111
+ */
1112
+ export function userAgentDrift(ua, chrome, where, browser = "Chrome") {
1113
+ if (!ua || !chrome)
1114
+ return null;
1115
+ const pinned = chromeMajorOf(ua);
1116
+ const have = Number(chrome.split(".")[0]);
1117
+ if (pinned === null || !Number.isFinite(have) || pinned === have)
1118
+ return null;
1119
+ 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}.`;
1120
+ }
1121
+ /**
1122
+ * What in a `web:` block works today but should change — never a refusal.
1123
+ * `check` prints these as warnings and the deploy reports them beside its
1124
+ * outward-gate warnings. `opts.chrome` / `opts.chromium`: the runner image's
1125
+ * Google Chrome and Playwright Chromium versions, when known
1126
+ * (FOLDRUN_RUNNER_ENGINES on the platform, /api/version from the CLI); an
1127
+ * engine whose version is unknown is not compared.
1128
+ */
1129
+ export function webWarnings(front, opts = {}) {
1130
+ const out = [];
1131
+ const browse = webConfig(front).raw.browse;
1132
+ if (!browse || typeof browse !== "object" || Array.isArray(browse))
1133
+ return out;
1134
+ const raw = browse;
1135
+ if (raw.session !== undefined && raw.session !== null && raw.vendor_session === undefined) {
1136
+ 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.");
1137
+ }
1138
+ const read = readBrowseSettings(browse);
1139
+ if (read.error)
1140
+ return out;
1141
+ const engine = read.settings.engine ?? "chromium";
1142
+ // Only an engine that IS Chrome sends Chrome's client hints; a Chrome UA on
1143
+ // Firefox is a different (deliberate) disguise this does not judge. Each
1144
+ // engine against its own version: chromium is Playwright's build (1.63's is
1145
+ // 153), not the image's Google Chrome (stable, often a major ahead).
1146
+ const drift = (eng, ua, where) => eng === "chrome" ? userAgentDrift(ua, opts.chrome, where, "Chrome")
1147
+ : eng === "chromium" ? userAgentDrift(ua, opts.chromium, where, "Chromium")
1148
+ : null;
1149
+ const w = drift(engine, read.settings.user_agent, "web.browse.user_agent");
1150
+ if (w)
1151
+ out.push(w);
1152
+ for (const [name, id] of Object.entries(read.settings.identities ?? {})) {
1153
+ const iw = drift(id.engine ?? engine, id.user_agent, `web.browse.identities.${name}.user_agent`);
1154
+ if (iw)
1155
+ out.push(iw);
1156
+ }
1157
+ return out;
1158
+ }
1159
+ /**
1160
+ * The agent's `web:` block, read once for everyone — the runner, `check`
1161
+ * and the deploy gate:
1162
+ *
1163
+ * tools: [web]
1164
+ * web:
1165
+ * actions: [search, fetch, crawl] # what it may do; all when absent
1166
+ * search: brave # who does each; absent is foldrun
1167
+ * browse: { engine: firefox } # search and browse also take their settings
1168
+ *
1169
+ * `raw` is each action's value as written. Browse alone cascades: the
1170
+ * workspace's (or account's) `web: {browse: …}` applies when the agent's
1171
+ * says nothing. A per-action key outside the block (`web_search:`) is not
1172
+ * read; it is a problem naming the block.
1173
+ */
1174
+ export function webConfig(front, workspaceFront) {
1175
+ const problems = [];
1176
+ const block = front.web;
1177
+ let web = {};
1178
+ if (block !== undefined && block !== null) {
1179
+ if (typeof block === "object" && !Array.isArray(block))
1180
+ web = block;
1181
+ else
1182
+ problems.push("web: is a block — actions: and a provider per action, e.g. `web: {fetch: jina}`.");
1183
+ }
1184
+ for (const k of Object.keys(web)) {
1185
+ if (k !== "actions" && !WEB_ACTIONS.includes(k)) {
1186
+ problems.push(`web.${k}: is not an action. The actions: ${WEB_ACTIONS.join(", ")}; and actions: for which of them the agent may use.`);
1187
+ }
1188
+ }
1189
+ for (const a of WEB_ACTIONS) {
1190
+ if (front[`web_${a}`] !== undefined)
1191
+ problems.push(`web_${a}: is not a key — write \`web: {${a}: …}\`.`);
1192
+ }
1193
+ let actions = null;
1194
+ if (web.actions !== undefined) {
1195
+ const list = Array.isArray(web.actions) ? web.actions.map((a) => String(a).trim().toLowerCase()) : null;
1196
+ const bad = list?.filter((a) => !WEB_ACTIONS.includes(a)) ?? [];
1197
+ if (!list)
1198
+ problems.push(`web.actions: is a list, e.g. [search, fetch].`);
1199
+ else if (bad.length)
1200
+ problems.push(`web.actions: ${bad.join(", ")} ${bad.length === 1 ? "is not an action" : "are not actions"}. The actions: ${WEB_ACTIONS.join(", ")}.`);
1201
+ else
1202
+ actions = [...new Set(list)];
1203
+ }
1204
+ const wsWeb = workspaceFront?.web && typeof workspaceFront.web === "object" && !Array.isArray(workspaceFront.web)
1205
+ ? workspaceFront.web : {};
1206
+ const raw = Object.fromEntries(WEB_ACTIONS.map((a) => {
1207
+ let v = web[a];
1208
+ if (v === undefined && a === "browse")
1209
+ v = wsWeb.browse;
1210
+ return [a, v];
1211
+ }));
1212
+ return { actions, raw, problems };
1213
+ }