@demigodmode/pi-web-agent 1.10.0 → 1.11.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/CHANGELOG.md CHANGED
@@ -18,6 +18,19 @@ The format is intentionally simple and release-oriented.
18
18
  ### Breaking
19
19
  - None.
20
20
 
21
+ ## [1.11.0] - 2026-09-16
22
+ ### Added
23
+ - Optional proxy support. Point web_explore at an HTTP/HTTPS proxy and everything outbound goes through it: search, fetch, Firecrawl, the GitHub/PDF/YouTube readers, doctor health checks, and the headless browser. Off unless you set it, and nothing changes if you don't. Set it in Settings → Backends, and keep credentials in `PI_WEB_AGENT_PROXY_USERNAME` / `PI_WEB_AGENT_PROXY_PASSWORD` rather than in the URL. Thanks to @lo-tp for building this. (#50)
24
+
25
+ ### Changed
26
+ - `/web-agent doctor` reports whether the jsdom compat patch is in place, and prints the command to reapply it if it isn't.
27
+
28
+ ### Fixed
29
+ - Pi failing to start with `Cannot find module 'punycode/'` or `Set operation called on non-Set object`. The patch for this was only applied at install time, but every pi extension shares one node_modules tree, so installing or updating anything else quietly reverted it. It now reapplies every time the extension loads. Also fixes it picking the wrong copy of the dependency when the shared tree holds more than one. (#34)
30
+
31
+ ### Breaking
32
+ - None.
33
+
21
34
  ## [1.10.0] - 2026-08-25
22
35
  ### Added
23
36
  - The zero-config default has a safety net now. When DuckDuckGo blocks a search (which happens fast on VPS and datacenter IPs), web search falls back to Tavily's keyless endpoint, no account or API key needed, so a fresh install still returns results instead of an error. Set `PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK=1` if you'd rather it just fail. (#42)
package/README.md CHANGED
@@ -4,9 +4,24 @@
4
4
 
5
5
  # pi-web-agent
6
6
 
7
- [![CI](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml/badge.svg)](https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml)
8
- [![npm version](https://img.shields.io/npm/v/@demigodmode/pi-web-agent)](https://www.npmjs.com/package/@demigodmode/pi-web-agent)
9
- [![Docs](https://img.shields.io/badge/docs-github%20pages-blue)](https://demigodmode.github.io/pi-web-agent/)
7
+ <p align="center">
8
+ <a href="https://github.com/demigodmode/pi-web-agent/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/demigodmode/pi-web-agent/ci.yml?branch=main&style=flat-square&logo=github&label=CI" alt="CI"></a>
9
+ <a href="https://www.npmjs.com/package/@demigodmode/pi-web-agent"><img src="https://img.shields.io/npm/v/@demigodmode/pi-web-agent?style=flat-square&logo=npm&logoColor=white&color=CB3837" alt="npm version"></a>
10
+ <a href="https://www.npmjs.com/package/@demigodmode/pi-web-agent"><img src="https://img.shields.io/npm/dm/@demigodmode/pi-web-agent?style=flat-square&color=0A7BBB&label=downloads" alt="npm downloads"></a>
11
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-475569?style=flat-square" alt="Platform: macOS, Linux, Windows">
12
+ <a href="https://demigodmode.github.io/pi-web-agent/"><img src="https://img.shields.io/badge/docs-github%20pages-2088FF?style=flat-square&logo=readthedocs&logoColor=white" alt="Docs"></a>
13
+ <a href="https://github.com/demigodmode/pi-web-agent/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/@demigodmode/pi-web-agent?style=flat-square&color=6F42C1" alt="License"></a>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <img src="https://img.shields.io/badge/search%20backends-475569?style=flat-square" alt="Search backends">
18
+ <img src="https://img.shields.io/badge/DuckDuckGo-475569?style=flat-square&logo=duckduckgo&logoColor=white" alt="DuckDuckGo">
19
+ <img src="https://img.shields.io/badge/SearXNG-475569?style=flat-square&logo=searxng&logoColor=white" alt="SearXNG">
20
+ <img src="https://img.shields.io/badge/Brave-475569?style=flat-square&logo=brave&logoColor=white" alt="Brave Search">
21
+ <img src="https://img.shields.io/badge/You.com-475569?style=flat-square" alt="You.com">
22
+ <img src="https://img.shields.io/badge/Exa-475569?style=flat-square" alt="Exa">
23
+ <img src="https://img.shields.io/badge/Tavily-475569?style=flat-square" alt="Tavily">
24
+ </p>
10
25
 
11
26
  One public tool, `web_explore`, that does bounded web research for Pi: search, fetch, targeted browser rendering, ranking, and honest caveats, all behind a single call.
12
27
 
@@ -26,7 +41,7 @@ One public tool, `web_explore`, that does bounded web research for Pi: search, f
26
41
 
27
42
  Compared to other web tooling for agents:
28
43
 
29
- - **Hands-off.** No curator, no browser windows to approve, no step that pops you out of your session. Ask `web_explore` once and the answer comes back with caveats. Nothing to babysit.
44
+ - **Hands-off.** No curator, no browser windows to approve, no step that pops you out of your session. Ask `web_explore` once and the answer comes back cleanly. Nothing to babysit.
30
45
  - **Keyless by default.** Search, page reads, and the GitHub/PDF/YouTube readers all work with no API keys. Add hosted providers only when you want them.
31
46
  - **Bounded and honest.** Compact output by default, and it says when a read was not good enough instead of returning fake confidence.
32
47
 
@@ -12,6 +12,11 @@ export type FirecrawlOptions = {
12
12
  formats?: string[];
13
13
  onlyMainContent?: boolean;
14
14
  };
15
+ export type ProxyConfig = {
16
+ url: string;
17
+ username?: string;
18
+ password?: string;
19
+ };
15
20
  export type SearchBackendConfig = {
16
21
  provider: 'duckduckgo' | 'searxng' | 'brave' | 'youcom' | 'exa' | 'tavily';
17
22
  baseUrl?: string;
@@ -33,11 +38,13 @@ export type BackendConfig = {
33
38
  search: SearchBackendConfig;
34
39
  fetch: FetchBackendConfig;
35
40
  headless: HeadlessBackendConfig;
41
+ proxy?: ProxyConfig;
36
42
  };
37
43
  export type BackendConfigOverride = {
38
44
  search?: Partial<SearchBackendConfig>;
39
45
  fetch?: Partial<FetchBackendConfig>;
40
46
  headless?: Partial<HeadlessBackendConfig>;
47
+ proxy?: ProxyConfig;
41
48
  };
42
49
  export type BackendConfigFile = {
43
50
  backends?: {
@@ -58,9 +65,29 @@ export type BackendConfigFile = {
58
65
  headless?: {
59
66
  provider?: unknown;
60
67
  };
68
+ proxy?: {
69
+ url?: unknown;
70
+ username?: unknown;
71
+ password?: unknown;
72
+ };
61
73
  };
62
74
  };
63
75
  export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
76
+ /**
77
+ * Remove any credentials (user:pass@) embedded in a proxy URL. pi-web-agent
78
+ * never reads or sends credentials from the URL; proxy auth comes from
79
+ * backends.proxy.username/password or PI_WEB_AGENT_PROXY_USERNAME /
80
+ * PI_WEB_AGENT_PROXY_PASSWORD. Stripping them here keeps them out of logs,
81
+ * doctor output, the settings UI, and the proxy connection itself.
82
+ */
83
+ export declare function stripProxyCredentials(url: string): string;
84
+ /**
85
+ * Whether a proxy url passes validation: a blank url is the "disable proxy"
86
+ * marker and passes; anything else must parse as an http(s) URL. This is a
87
+ * pure syntax check — no connectivity check is performed.
88
+ */
89
+ export declare function isValidProxyUrl(url: string): boolean;
90
+ export declare function extractProxyConfig(value: unknown): ProxyConfig | undefined;
64
91
  export declare function usableSearchProviders(search: SearchBackendConfig, env?: NodeJS.ProcessEnv): SearchProviderName[];
65
92
  export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
66
93
  export declare function validateBackendConfig(config: BackendConfig): string[];
@@ -9,6 +9,76 @@ function extractStringArray(value) {
9
9
  const strings = value.filter((item) => typeof item === 'string');
10
10
  return strings.length === value.length ? strings : undefined;
11
11
  }
12
+ /**
13
+ * Remove any credentials (user:pass@) embedded in a proxy URL. pi-web-agent
14
+ * never reads or sends credentials from the URL; proxy auth comes from
15
+ * backends.proxy.username/password or PI_WEB_AGENT_PROXY_USERNAME /
16
+ * PI_WEB_AGENT_PROXY_PASSWORD. Stripping them here keeps them out of logs,
17
+ * doctor output, the settings UI, and the proxy connection itself.
18
+ */
19
+ export function stripProxyCredentials(url) {
20
+ let parsed;
21
+ try {
22
+ parsed = new URL(url);
23
+ }
24
+ catch {
25
+ return url;
26
+ }
27
+ if (!parsed.username && !parsed.password)
28
+ return url; // already credential-free
29
+ parsed.username = '';
30
+ parsed.password = '';
31
+ return parsed.toString().replace(/\/$/, '');
32
+ }
33
+ /**
34
+ * Whether a proxy url passes validation: a blank url is the "disable proxy"
35
+ * marker and passes; anything else must parse as an http(s) URL. This is a
36
+ * pure syntax check — no connectivity check is performed.
37
+ */
38
+ export function isValidProxyUrl(url) {
39
+ if (url.trim() === '')
40
+ return true;
41
+ try {
42
+ const parsed = new URL(url);
43
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ export function extractProxyConfig(value) {
50
+ if (!value || typeof value !== 'object')
51
+ return undefined;
52
+ const raw = value;
53
+ // An explicitly present but blank url is the "disable proxy" marker: it lets a
54
+ // higher-priority layer (e.g. a project) clear a proxy set in a lower layer.
55
+ if (typeof raw.url === 'string' && raw.url.trim() === '') {
56
+ return { url: '' };
57
+ }
58
+ if (typeof raw.url !== 'string' || !raw.url.trim())
59
+ return undefined;
60
+ const url = raw.url.trim();
61
+ let parsed;
62
+ try {
63
+ parsed = new URL(url);
64
+ }
65
+ catch {
66
+ parsed = undefined;
67
+ }
68
+ // A valid url is normalized. A malformed url is kept as-is (rather than
69
+ // dropped) so validation can flag it and the backend factory can fail loudly
70
+ // instead of silently sending traffic direct to the websites.
71
+ const config = {
72
+ url: parsed && (parsed.protocol === 'http:' || parsed.protocol === 'https:')
73
+ ? parsed.toString().replace(/\/$/, '')
74
+ : url
75
+ };
76
+ if (typeof raw.username === 'string' && raw.username.trim())
77
+ config.username = raw.username;
78
+ if (typeof raw.password === 'string')
79
+ config.password = raw.password;
80
+ return config;
81
+ }
12
82
  function extractSearxngOptions(value) {
13
83
  if (!value || typeof value !== 'object')
14
84
  return undefined;
@@ -113,6 +183,10 @@ export function extractBackendConfigOverride(file) {
113
183
  if (backends?.headless?.provider === 'local-browser') {
114
184
  override.headless = { provider: 'local-browser' };
115
185
  }
186
+ const proxy = extractProxyConfig(backends?.proxy);
187
+ if (proxy) {
188
+ override.proxy = proxy;
189
+ }
116
190
  return override;
117
191
  }
118
192
  export function validateBackendConfig(config) {
@@ -120,6 +194,23 @@ export function validateBackendConfig(config) {
120
194
  if (config.search.provider === 'searxng' && !config.search.baseUrl) {
121
195
  issues.push('search provider searxng requires backends.search.baseUrl');
122
196
  }
197
+ if (config.proxy && config.proxy.url.trim() !== '') {
198
+ let parsed;
199
+ try {
200
+ parsed = new URL(config.proxy.url);
201
+ }
202
+ catch {
203
+ parsed = undefined;
204
+ }
205
+ if (!isValidProxyUrl(config.proxy.url)) {
206
+ issues.push('backends.proxy.url must be an http or https URL');
207
+ }
208
+ else if (parsed && (parsed.username || parsed.password)) {
209
+ // Credentials belong in backends.proxy.username/password or the env vars
210
+ // below, never in the URL itself.
211
+ issues.push('backends.proxy.url must not include credentials (user:pass@); set PI_WEB_AGENT_PROXY_USERNAME and PI_WEB_AGENT_PROXY_PASSWORD (or backends.proxy.username / backends.proxy.password) instead');
212
+ }
213
+ }
123
214
  if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
124
215
  issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
125
216
  }
@@ -173,6 +264,11 @@ export function mergeBackendConfigLayers(...layers) {
173
264
  return layers.reduce((merged, layer) => ({
174
265
  search: mergeSearchConfig(merged.search, layer?.search),
175
266
  fetch: mergeFetchConfig(merged.fetch, layer?.fetch),
176
- headless: { ...merged.headless, ...layer?.headless }
267
+ headless: { ...merged.headless, ...layer?.headless },
268
+ proxy: layer?.proxy
269
+ ? layer.proxy.url === ''
270
+ ? undefined // explicit disable overrides any proxy from lower layers
271
+ : { ...merged.proxy, ...layer.proxy }
272
+ : merged.proxy
177
273
  }), DEFAULT_BACKEND_CONFIG);
178
274
  }
@@ -8,7 +8,7 @@ import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
8
8
  import { createWebFetchTool } from '../tools/web-fetch.js';
9
9
  import { createWebSearchTool } from '../tools/web-search.js';
10
10
  import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
11
- import { type BackendConfig } from './config.js';
11
+ import { type BackendConfig, type ProxyConfig } from './config.js';
12
12
  export type BackendSet = {
13
13
  search: (input: {
14
14
  query: string;
@@ -30,5 +30,6 @@ export type BackendFactoryDeps = {
30
30
  createHttpFetch?: typeof createWebFetchTool;
31
31
  createFirecrawlFetch?: typeof createFirecrawlFetcher;
32
32
  createHeadlessFetch?: typeof createWebFetchHeadlessTool;
33
+ createProxyFetch?: (proxy: ProxyConfig) => typeof fetch;
33
34
  };
34
35
  export declare function createBackendSet(config?: BackendConfig, deps?: BackendFactoryDeps): BackendSet;
@@ -1,6 +1,10 @@
1
1
  import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
2
+ import { createHttpFetcher } from '../fetch/http-fetch.js';
3
+ import { createProxyFetch, resolveProxyCredentials } from '../fetch/proxy-fetch.js';
4
+ import { headlessFetch } from '../fetch/headless-fetch.js';
2
5
  import { createBraveSearchTool } from '../search/brave.js';
3
6
  import { createYouComSearchTool } from '../search/youcom.js';
7
+ import { fetchDuckDuckGoHtml } from '../search/duckduckgo.js';
4
8
  import { createExaSearchTool } from '../search/exa.js';
5
9
  import { createTavilySearchTool } from '../search/tavily.js';
6
10
  import { createSearxngSearchTool } from '../search/searxng.js';
@@ -10,7 +14,7 @@ import { buildSearchPresentation } from '../presentation/search-presentation.js'
10
14
  import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
11
15
  import { createWebFetchTool } from '../tools/web-fetch.js';
12
16
  import { createWebSearchTool } from '../tools/web-search.js';
13
- import { DEFAULT_BACKEND_CONFIG, usableSearchProviders } from './config.js';
17
+ import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, stripProxyCredentials, usableSearchProviders } from './config.js';
14
18
  import { createSpecialContentResolver } from '../readers/resolver.js';
15
19
  import { createGithubReader } from '../readers/github-reader.js';
16
20
  import { createPdfReader } from '../readers/pdf-reader.js';
@@ -90,52 +94,104 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
90
94
  const createHttpFetch = deps.createHttpFetch ?? createWebFetchTool;
91
95
  const createFirecrawlFetch = deps.createFirecrawlFetch ?? createFirecrawlFetcher;
92
96
  const createHeadlessFetch = deps.createHeadlessFetch ?? createWebFetchHeadlessTool;
97
+ const makeProxyFetch = deps.createProxyFetch ?? createProxyFetch;
98
+ // A blank url is the "disable proxy" marker: treat it as no proxy at all.
99
+ const proxy = config.proxy && config.proxy.url.trim() !== '' ? config.proxy : undefined;
100
+ // A configured proxy whose url fails validation must not be silently ignored
101
+ // — that would send traffic direct to the websites. Every request errors out
102
+ // instead. No connectivity check is needed: the url itself is the problem.
103
+ if (proxy && !isValidProxyUrl(proxy.url)) {
104
+ const message = `backends.proxy.url (${proxy.url}) is not a valid http or https URL. ` +
105
+ 'Web requests are blocked until it is fixed; set backends.proxy.url to "" to disable the proxy.';
106
+ return {
107
+ search: async () => {
108
+ const result = {
109
+ status: 'error',
110
+ results: [],
111
+ metadata: { backend: config.search.provider, cacheHit: false },
112
+ error: { code: 'BACKEND_CONFIG_INVALID', message }
113
+ };
114
+ return { ...result, presentation: buildSearchPresentation(result) };
115
+ },
116
+ fetchPage: async ({ url }) => {
117
+ const result = {
118
+ status: 'error',
119
+ url,
120
+ metadata: { method: 'http', cacheHit: false },
121
+ error: { code: 'BACKEND_CONFIG_INVALID', message }
122
+ };
123
+ return { ...result, presentation: buildFetchPresentation(result) };
124
+ },
125
+ headlessFetch: async ({ url }) => {
126
+ const result = {
127
+ status: 'error',
128
+ url,
129
+ metadata: { method: 'headless', cacheHit: false },
130
+ error: { code: 'BACKEND_CONFIG_INVALID', message }
131
+ };
132
+ return { ...result, presentation: buildFetchPresentation(result) };
133
+ }
134
+ };
135
+ }
136
+ // When a proxy is configured, every outbound HTTP request (search, fetch,
137
+ // readers, and doctor-style checks) goes through it; headless browser
138
+ // traffic gets the same proxy via Playwright launch options.
139
+ const fetchImpl = proxy ? makeProxyFetch(proxy) : fetch;
140
+ const proxyCredentials = proxy ? resolveProxyCredentials(proxy) : undefined;
141
+ const proxyBrowserOptions = proxy
142
+ ? {
143
+ server: stripProxyCredentials(proxy.url),
144
+ ...(proxyCredentials?.username !== undefined ? { username: proxyCredentials.username } : {}),
145
+ ...(proxyCredentials?.password !== undefined ? { password: proxyCredentials.password } : {})
146
+ }
147
+ : undefined;
148
+ const createDuckDuckGo = () => createDuckDuckGoSearch({ searchHtml: (query) => fetchDuckDuckGoHtml(query, { fetchImpl }) });
93
149
  function buildProviderSearch(name) {
94
150
  switch (name) {
95
151
  case 'searxng':
96
152
  return config.search.baseUrl
97
- ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
153
+ ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options, fetchImpl })
98
154
  : invalidSearxngSearch();
99
155
  case 'brave':
100
- return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY });
156
+ return createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl });
101
157
  case 'youcom':
102
- return createYouComSearch({ apiKey: process.env.YDC_API_KEY });
158
+ return createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl });
103
159
  case 'exa':
104
- return createExaSearch({ apiKey: process.env.EXA_API_KEY });
160
+ return createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl });
105
161
  case 'tavily':
106
- return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY });
162
+ return createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl });
107
163
  case 'duckduckgo':
108
164
  default:
109
- return createDuckDuckGoSearch();
165
+ return createDuckDuckGo();
110
166
  }
111
167
  }
112
168
  let search = config.search.provider === 'searxng'
113
169
  ? config.search.baseUrl
114
- ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options })
170
+ ? createSearxngSearch({ baseUrl: config.search.baseUrl, options: config.search.options, fetchImpl })
115
171
  : invalidSearxngSearch()
116
172
  : config.search.provider === 'brave'
117
- ? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY })
173
+ ? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl })
118
174
  : config.search.provider === 'youcom'
119
- ? createYouComSearch({ apiKey: process.env.YDC_API_KEY })
175
+ ? createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl })
120
176
  : config.search.provider === 'exa'
121
- ? createExaSearch({ apiKey: process.env.EXA_API_KEY })
177
+ ? createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl })
122
178
  : config.search.provider === 'tavily'
123
- ? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY })
124
- : createDuckDuckGoSearch();
179
+ ? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl })
180
+ : createDuckDuckGo();
125
181
  if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
126
- search = withSearchFallback(search, createDuckDuckGoSearch(), 'searxng');
182
+ search = withSearchFallback(search, createDuckDuckGo(), 'searxng');
127
183
  }
128
184
  if (config.search.provider === 'brave' && config.search.fallback === 'duckduckgo') {
129
- search = withSearchFallback(search, createDuckDuckGoSearch(), 'brave');
185
+ search = withSearchFallback(search, createDuckDuckGo(), 'brave');
130
186
  }
131
187
  if (config.search.provider === 'youcom' && config.search.fallback === 'duckduckgo') {
132
- search = withSearchFallback(search, createDuckDuckGoSearch(), 'youcom');
188
+ search = withSearchFallback(search, createDuckDuckGo(), 'youcom');
133
189
  }
134
190
  if (config.search.provider === 'exa' && config.search.fallback === 'duckduckgo') {
135
- search = withSearchFallback(search, createDuckDuckGoSearch(), 'exa');
191
+ search = withSearchFallback(search, createDuckDuckGo(), 'exa');
136
192
  }
137
193
  if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
138
- search = withSearchFallback(search, createDuckDuckGoSearch(), 'tavily');
194
+ search = withSearchFallback(search, createDuckDuckGo(), 'tavily');
139
195
  }
140
196
  const fanoutConfig = config.search.fanout;
141
197
  if (fanoutConfig && fanoutConfig.mode !== 'off') {
@@ -158,16 +214,17 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
158
214
  const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
159
215
  const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
160
216
  if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
161
- search = withSearchFallback(search, createTavilySearch({ keyless: true }), 'duckduckgo');
217
+ search = withSearchFallback(search, createTavilySearch({ keyless: true, fetchImpl }), 'duckduckgo');
162
218
  }
163
- const httpFetch = createHttpFetch();
219
+ const httpFetch = createHttpFetch({ fetchPage: createHttpFetcher({ fetchImpl }) });
164
220
  let fetchPage = config.fetch.provider === 'firecrawl'
165
221
  ? config.fetch.baseUrl
166
222
  ? createHttpFetch({
167
223
  fetchPage: createFirecrawlFetch({
168
224
  baseUrl: config.fetch.baseUrl,
169
225
  apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
170
- options: config.fetch.options
226
+ options: config.fetch.options,
227
+ fetchImpl
171
228
  })
172
229
  })
173
230
  : createHttpFetch({ fetchPage: invalidFirecrawlFetch() })
@@ -176,12 +233,13 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
176
233
  fetchPage = withFetchFallback(fetchPage, httpFetch);
177
234
  }
178
235
  const fetchPageWithReaders = createSpecialContentResolver({
179
- readers: [createGithubReader(), createPdfReader(), createYoutubeReader()],
236
+ readers: [createGithubReader({ fetchImpl }), createPdfReader({ fetchImpl }), createYoutubeReader({ fetchImpl })],
180
237
  fallback: fetchPage
181
238
  });
239
+ const headlessPage = (url) => proxyBrowserOptions ? headlessFetch(url, { proxy: proxyBrowserOptions }) : headlessFetch(url);
182
240
  return {
183
241
  search,
184
242
  fetchPage: fetchPageWithReaders,
185
- headlessFetch: createHeadlessFetch()
243
+ headlessFetch: createHeadlessFetch({ fetchPage: headlessPage })
186
244
  };
187
245
  }
@@ -16,6 +16,9 @@ type CommandDeps = {
16
16
  arch: string;
17
17
  };
18
18
  checkTypebox?: () => Promise<boolean>;
19
+ checkJitiCompat?: () => {
20
+ pending: string[];
21
+ };
19
22
  checkBackends?: (config: BackendConfig) => Promise<string[]>;
20
23
  getChangelog?: () => Promise<string | undefined>;
21
24
  };
@@ -1,10 +1,12 @@
1
- import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
1
+ import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, mergeBackendConfigLayers, stripProxyCredentials, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
2
2
  import { checkBackendHealth } from '../backends/doctor.js';
3
3
  import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
4
4
  import { Container, Input, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
5
5
  import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
6
6
  import { loadPresentationConfigLayers, resetPresentationConfigScope, saveBackendConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
7
7
  import { resolveBrowserExecutable } from '../fetch/browser-resolution.js';
8
+ import { createProxyFetch } from '../fetch/proxy-fetch.js';
9
+ import { checkJitiCompat } from '../jiti-compat.js';
8
10
  import { getLatestChangelogEntry } from '../changelog-notice.js';
9
11
  const PRESENTATION_TOOL_NAMES = ['web_explore'];
10
12
  function parseScopeToken(token) {
@@ -29,7 +31,8 @@ function cloneBackendConfig(config) {
29
31
  ...config.fetch,
30
32
  options: config.fetch.options ? { ...config.fetch.options } : undefined
31
33
  },
32
- headless: { ...config.headless }
34
+ headless: { ...config.headless },
35
+ proxy: config.proxy ? { ...config.proxy } : undefined
33
36
  };
34
37
  }
35
38
  function sameJson(left, right) {
@@ -47,6 +50,21 @@ export function validateBackendUrl(value) {
47
50
  return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
48
51
  }
49
52
  }
53
+ /**
54
+ * The extension re-applies this patch on load, so `pending` here means the
55
+ * patch could not be written (read-only install, permissions, or a dependency
56
+ * whose shape changed). Point at the manual script in that case: it is the
57
+ * same recovery step people have been finding by digging through issue #34.
58
+ */
59
+ function formatJitiCompatLine(status) {
60
+ if (status.pending.length === 0)
61
+ return 'jsdom compat patch: ok';
62
+ return [
63
+ `jsdom compat patch: needed (${status.pending.join(', ')})`,
64
+ 'Run: node ~/.pi/agent/npm/node_modules/@demigodmode/pi-web-agent/scripts/patch-jiti-compat.mjs',
65
+ 'then restart Pi.'
66
+ ].join('\n');
67
+ }
50
68
  async function defaultCheckTypebox() {
51
69
  try {
52
70
  await import('typebox');
@@ -87,8 +105,11 @@ function formatBackendSummary(config = DEFAULT_BACKEND_CONFIG) {
87
105
  return [
88
106
  searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
89
107
  fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
90
- `headless: ${config.headless.provider}`
91
- ].join('\n');
108
+ `headless: ${config.headless.provider}`,
109
+ config.proxy ? `proxy: ${stripProxyCredentials(config.proxy.url)}` : undefined
110
+ ]
111
+ .filter(Boolean)
112
+ .join('\n');
92
113
  }
93
114
  function formatConfigSummary(config) {
94
115
  const lines = [`defaultMode: ${config.defaultMode}`];
@@ -249,6 +270,12 @@ function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange
249
270
  label: 'Firecrawl API key',
250
271
  currentValue: 'env var',
251
272
  values: ['env var']
273
+ },
274
+ {
275
+ id: 'backend:proxy:url',
276
+ label: 'Proxy URL',
277
+ currentValue: backends.proxy ? stripProxyCredentials(backends.proxy.url) : 'not set',
278
+ submenu: createBackendUrlEditor(theme, 'HTTP proxy URL', 'http://127.0.0.1:7890', onUrlEditorOpenChange)
252
279
  }
253
280
  ];
254
281
  }
@@ -421,6 +448,14 @@ export function applySettingsValue(state, id, newValue) {
421
448
  delete currentBackends.fetch.baseUrl;
422
449
  }
423
450
  }
451
+ if (id === 'backend:proxy:url') {
452
+ if (newValue.trim()) {
453
+ currentBackends.proxy = { ...currentBackends.proxy, url: newValue.trim() };
454
+ }
455
+ else {
456
+ delete currentBackends.proxy;
457
+ }
458
+ }
424
459
  nextDrafts[nextScope] = currentDraft;
425
460
  nextBackendDrafts[nextScope] = currentBackends;
426
461
  return {
@@ -485,6 +520,17 @@ export function collapseBackendConfigToOverride(config, inheritedConfig) {
485
520
  if (!sameJson(config.headless, inheritedConfig.headless)) {
486
521
  override.headless = { ...config.headless };
487
522
  }
523
+ if (!sameJson(config.proxy, inheritedConfig.proxy)) {
524
+ if (config.proxy) {
525
+ const { password: _password, ...proxy } = config.proxy;
526
+ override.proxy = { ...proxy };
527
+ }
528
+ else if (inheritedConfig.proxy) {
529
+ // The proxy was cleared at this scope; record an explicit disable so it
530
+ // overrides a proxy set in a lower (e.g. global) layer.
531
+ override.proxy = { url: '' };
532
+ }
533
+ }
488
534
  return override;
489
535
  }
490
536
  export function handleSettingsShortcut(data) {
@@ -669,7 +715,12 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
669
715
  arch: process.arch
670
716
  };
671
717
  const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
672
- const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config));
718
+ const checkCompat = deps.checkJitiCompat ?? checkJitiCompat;
719
+ const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config, {
720
+ // An invalid proxy url is reported by validateBackendConfig below; never
721
+ // build a proxy agent from it (no connectivity check is attempted).
722
+ fetchImpl: config.proxy && isValidProxyUrl(config.proxy.url) ? createProxyFetch(config.proxy) : fetch
723
+ }));
673
724
  const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
674
725
  pi.registerCommand('web-agent', {
675
726
  description: 'Open settings or manage pi-web-agent presentation config',
@@ -700,6 +751,7 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
700
751
  'pi-web-agent: loaded',
701
752
  `runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
702
753
  `typebox: ${typeboxOk ? 'ok' : 'missing'}`,
754
+ formatJitiCompatLine(checkCompat()),
703
755
  formatBackendSummary(backendConfig),
704
756
  backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
705
757
  ...backendHealth
@@ -1,2 +1,3 @@
1
+ import './jiti-compat-run.js';
1
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
3
  export default function extension(pi: ExtensionAPI): void;
package/dist/extension.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Keep first: re-applies the jsdom/jiti compat patch before jsdom loads (#34).
2
+ import './jiti-compat-run.js';
1
3
  import { DEFAULT_BACKEND_CONFIG } from './backends/config.js';
2
4
  import { Type } from 'typebox';
3
5
  import { registerWebAgentConfigCommands } from './commands/web-agent-config.js';
@@ -1,13 +1,20 @@
1
1
  import { type BrowserResolutionResult } from './browser-resolution.js';
2
2
  import type { WebFetchHeadlessResponse } from '../types.js';
3
- export declare function headlessFetch(url: string, { configuredPath, resolveBrowser, launchBrowser, now }?: {
3
+ export type BrowserProxyOptions = {
4
+ server: string;
5
+ username?: string;
6
+ password?: string;
7
+ };
8
+ export declare function headlessFetch(url: string, { configuredPath, proxy, resolveBrowser, launchBrowser, now }?: {
4
9
  configuredPath?: string;
10
+ proxy?: BrowserProxyOptions;
5
11
  resolveBrowser?: (options?: {
6
12
  configuredPath?: string;
7
13
  }) => Promise<BrowserResolutionResult>;
8
14
  launchBrowser?: (options: {
9
15
  executablePath?: string;
10
16
  headless: true;
17
+ proxy?: BrowserProxyOptions;
11
18
  }) => Promise<{
12
19
  newContext: () => Promise<{
13
20
  newPage: () => Promise<any>;
@@ -7,7 +7,7 @@ function cleanupRenderedText(text) {
7
7
  cleaned = cleaned.replace(/\s+/g, ' ').trim();
8
8
  return cleaned;
9
9
  }
10
- export async function headlessFetch(url, { configuredPath, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless }) => chromium.launch(executablePath ? { executablePath, headless } : { headless }), now = () => Date.now() } = {}) {
10
+ export async function headlessFetch(url, { configuredPath, proxy, resolveBrowser = (options) => resolveBrowserExecutable({ configuredPath: options?.configuredPath }), launchBrowser = ({ executablePath, headless, proxy }) => chromium.launch(executablePath ? { executablePath, headless, ...(proxy ? { proxy } : {}) } : { headless, ...(proxy ? { proxy } : {}) }), now = () => Date.now() } = {}) {
11
11
  const resolved = await resolveBrowser({ configuredPath });
12
12
  if (!resolved.ok && resolved.error.code === 'CONFIGURED_BROWSER_NOT_FOUND') {
13
13
  return {
@@ -19,8 +19,8 @@ export async function headlessFetch(url, { configuredPath, resolveBrowser = (opt
19
19
  }
20
20
  const browserName = resolved.ok ? resolved.browser : 'chromium';
21
21
  const launchOptions = resolved.ok
22
- ? { executablePath: resolved.executablePath, headless: true }
23
- : { headless: true };
22
+ ? { executablePath: resolved.executablePath, headless: true, ...(proxy ? { proxy } : {}) }
23
+ : { headless: true, ...(proxy ? { proxy } : {}) };
24
24
  let browser;
25
25
  let context;
26
26
  let page;
@@ -0,0 +1,22 @@
1
+ import { type ProxyConfig } from '../backends/config.js';
2
+ export type ProxyCredentials = {
3
+ username?: string;
4
+ password?: string;
5
+ };
6
+ export type ProxyFetchOptions = {
7
+ /** TLS options for the tunneled connection to the origin (https targets). */
8
+ tls?: {
9
+ rejectUnauthorized?: boolean;
10
+ };
11
+ };
12
+ /**
13
+ * Resolve proxy credentials from the config, falling back to environment
14
+ * variables so secrets can stay out of config files (same policy as API keys).
15
+ */
16
+ export declare function resolveProxyCredentials(proxy: ProxyConfig, env?: NodeJS.ProcessEnv): ProxyCredentials;
17
+ /**
18
+ * Build a fetch implementation that routes all traffic through the configured
19
+ * HTTP/HTTPS proxy. Uses undici (the engine behind Node's global fetch) with a
20
+ * ProxyAgent dispatcher while keeping the standard fetch API surface.
21
+ */
22
+ export declare function createProxyFetch(proxy: ProxyConfig, options?: ProxyFetchOptions): typeof fetch;
@@ -0,0 +1,46 @@
1
+ import { fetch as undiciFetch, ProxyAgent } from 'undici';
2
+ import { stripProxyCredentials } from '../backends/config.js';
3
+ /**
4
+ * Resolve proxy credentials from the config, falling back to environment
5
+ * variables so secrets can stay out of config files (same policy as API keys).
6
+ */
7
+ export function resolveProxyCredentials(proxy, env = process.env) {
8
+ const username = proxy.username ?? env.PI_WEB_AGENT_PROXY_USERNAME;
9
+ const password = proxy.password ?? env.PI_WEB_AGENT_PROXY_PASSWORD;
10
+ return {
11
+ username: username?.trim() ? username : undefined,
12
+ password: password ? password : undefined
13
+ };
14
+ }
15
+ /**
16
+ * Build a fetch implementation that routes all traffic through the configured
17
+ * HTTP/HTTPS proxy. Uses undici (the engine behind Node's global fetch) with a
18
+ * ProxyAgent dispatcher while keeping the standard fetch API surface.
19
+ */
20
+ export function createProxyFetch(proxy, options = {}) {
21
+ const credentials = resolveProxyCredentials(proxy);
22
+ const agent = new ProxyAgent({
23
+ // Never trust credentials embedded in the URL; they come from config fields
24
+ // or the PI_WEB_AGENT_PROXY_* env vars via resolveProxyCredentials.
25
+ uri: stripProxyCredentials(proxy.url),
26
+ // undici's `token` option is used verbatim as the Proxy-Authorization header
27
+ // value for both CONNECT tunnels and forwarded HTTP requests.
28
+ ...(credentials.username !== undefined
29
+ ? {
30
+ token: `Basic ${Buffer.from(credentials.password !== undefined
31
+ ? `${credentials.username}:${credentials.password}`
32
+ : `${credentials.username}:`).toString('base64')}`
33
+ }
34
+ : {}),
35
+ ...(options.tls ? { requestTls: { rejectUnauthorized: options.tls.rejectUnauthorized } } : {})
36
+ });
37
+ // undici's Request/Response types are structurally near-identical to Node's
38
+ // global fetch types but not nominatively identical, so the wrapper is cast
39
+ // to the standard fetch signature (runtime behavior is identical: Node's
40
+ // global fetch is built on this same undici engine).
41
+ const proxyFetch = (input, init) => undiciFetch(input, {
42
+ ...init,
43
+ dispatcher: agent
44
+ });
45
+ return proxyFetch;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ import { ensureJitiCompat } from './jiti-compat.js';
2
+ // Side-effect-only module. It exists so `extension.ts` can run the compat
3
+ // patch as its *first* import: ESM evaluates a module's dependency subtree,
4
+ // including that module's body, before moving on to the next import. A plain
5
+ // `ensureJitiCompat()` call in the extension body would run too late, after
6
+ // jsdom (and therefore tr46/cssstyle) had already been evaluated.
7
+ //
8
+ // Keep this import first in extension.ts.
9
+ ensureJitiCompat();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Filesystem/resolver seam so tests can point this at a throwaway directory
3
+ * instead of the real `node_modules/tr46` and `node_modules/cssstyle`. All
4
+ * production call sites use the defaults (real `fs` + real module
5
+ * resolution) and never pass this in.
6
+ */
7
+ export type JitiCompatDeps = {
8
+ /**
9
+ * Resolve `specifier` as `fromFile` would. Omitting `fromFile` resolves from
10
+ * this module, which is only correct when nothing else claims the package.
11
+ */
12
+ resolve: (specifier: string, fromFile?: string) => string;
13
+ existsSync: (path: string) => boolean;
14
+ readFileSync: (path: string) => string;
15
+ writeFileSync: (path: string, contents: string) => void;
16
+ };
17
+ export type JitiCompatStatus = {
18
+ /** Files that still need patching. Empty means the tree is healthy. */
19
+ pending: string[];
20
+ /** Files patched during this call. */
21
+ patched: string[];
22
+ };
23
+ /**
24
+ * Apply both patches if they are missing. Safe to call repeatedly: an already
25
+ * patched tree is a few `readFileSync` calls and no writes. Never throws.
26
+ */
27
+ export declare function ensureJitiCompat(deps?: JitiCompatDeps): JitiCompatStatus;
28
+ /**
29
+ * Read-only view of the same checks, for `/web-agent doctor`. Never throws and
30
+ * never writes.
31
+ */
32
+ export declare function checkJitiCompat(deps?: JitiCompatDeps): JitiCompatStatus;
@@ -0,0 +1,215 @@
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, relative, sep } from 'node:path';
4
+ /**
5
+ * Works around two incompatibilities between pi's extension loader (a patched
6
+ * jiti) and jsdom's dependency tree:
7
+ *
8
+ * 1. jiti can't resolve the trailing-slash bare specifier require("punycode/")
9
+ * used by tr46.
10
+ * 2. jiti wraps `module.exports = new Set(...)` (cssstyle) in a Proxy, which
11
+ * breaks native Set methods on the exported value.
12
+ *
13
+ * Both files live in the shared `~/.pi/agent/npm/node_modules` tree, so
14
+ * installing or updating any *other* pi extension re-extracts them and reverts
15
+ * the patch. A postinstall hook alone therefore can't keep this healthy, which
16
+ * is why `ensureJitiCompat()` also runs on extension load, before jsdom is
17
+ * evaluated. See https://github.com/demigodmode/pi-web-agent/issues/34.
18
+ *
19
+ * scripts/patch-jiti-compat.mjs duplicates this logic for the postinstall
20
+ * hook. That hook runs before `npm run build`, so it cannot import dist/.
21
+ * Keep the two in sync.
22
+ */
23
+ const requireFromHere = createRequire(import.meta.url);
24
+ /**
25
+ * Write via a temp file + rename rather than in place. Two reasons, both of
26
+ * which bite harder now that this runs on every extension load instead of once
27
+ * per install:
28
+ *
29
+ * - `writeFileSync` truncates first. A crash, a full disk, or an OOM kill
30
+ * mid-write leaves a half-written file that still contains the marker
31
+ * comment, so every later run would skip it as "already patched" and the
32
+ * doctor would report a broken tree as healthy.
33
+ * - Another Pi session can be `require`-ing the same file while we write it.
34
+ * `rename` is atomic within a filesystem, so readers see the old file or
35
+ * the new one, never a partial one.
36
+ *
37
+ * The rename also breaks a pnpm-style hardlink instead of writing through it
38
+ * into the shared content-addressable store.
39
+ */
40
+ function writeFileAtomic(path, contents) {
41
+ const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
42
+ try {
43
+ writeFileSync(temp, contents);
44
+ renameSync(temp, path);
45
+ }
46
+ catch (err) {
47
+ try {
48
+ if (existsSync(temp))
49
+ unlinkSync(temp);
50
+ }
51
+ catch {
52
+ // Best effort. Leaving a stray temp file is better than masking the
53
+ // original write failure.
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+ const defaultDeps = {
59
+ resolve: (specifier, fromFile) => (fromFile ? createRequire(fromFile) : requireFromHere).resolve(specifier),
60
+ existsSync,
61
+ readFileSync: (path) => readFileSync(path, 'utf8'),
62
+ writeFileSync: writeFileAtomic
63
+ };
64
+ const SET_SHIM_MARKER = 'pi/jiti workaround';
65
+ const SET_SHIM = `
66
+ // ${SET_SHIM_MARKER}: expose bound native Set methods as own properties so a
67
+ // Proxy wrapper around this export does not break Set brand checks.
68
+ for (const k of ["has", "add", "delete", "forEach", "keys", "values", "entries"]) {
69
+ module.exports[k] = Set.prototype[k].bind(module.exports);
70
+ }
71
+ module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exports);
72
+ `;
73
+ const PUNYCODE_SPECIFIER = 'require("punycode/")';
74
+ const PUNYCODE_REPLACEMENT = 'require("punycode/punycode.js")';
75
+ function findPackageRoot(deps, entryFile, packageName) {
76
+ let directory = dirname(entryFile);
77
+ for (;;) {
78
+ const manifestFile = join(directory, 'package.json');
79
+ if (deps.existsSync(manifestFile)) {
80
+ try {
81
+ const manifest = JSON.parse(deps.readFileSync(manifestFile));
82
+ if (manifest.name === packageName)
83
+ return directory;
84
+ }
85
+ catch {
86
+ // An unreadable or malformed package.json on the way up is not our
87
+ // problem to report. Keep walking.
88
+ }
89
+ }
90
+ const parent = dirname(directory);
91
+ if (parent === directory) {
92
+ throw new Error(`could not find the ${packageName} package root from ${entryFile}`);
93
+ }
94
+ directory = parent;
95
+ }
96
+ }
97
+ /**
98
+ * Resolve the copies jsdom actually loads, not whichever copy happens to sit
99
+ * highest in the tree.
100
+ *
101
+ * `~/.pi/agent/npm/node_modules` is shared by every pi extension, so version
102
+ * conflicts routinely push a second copy of a package into a nested
103
+ * `node_modules`. Resolving `cssstyle` or `tr46` from *this* module can then
104
+ * find a hoisted copy that jsdom never requires: the patch lands on the wrong
105
+ * file, the load still fails, and the doctor reports a healthy tree because it
106
+ * checked the same wrong file. Walk the real import chain instead
107
+ * (jsdom -> cssstyle, jsdom -> whatwg-url -> tr46) and only fall back to a
108
+ * direct resolve when jsdom is not resolvable at all.
109
+ */
110
+ function resolveFromJsdom(deps, specifier, via) {
111
+ try {
112
+ const jsdomEntry = deps.resolve('jsdom');
113
+ const importer = via ? deps.resolve(via, jsdomEntry) : jsdomEntry;
114
+ return deps.resolve(specifier, importer);
115
+ }
116
+ catch {
117
+ // jsdom (or the intermediate package) is not resolvable from here, e.g. a
118
+ // layout we do not recognize. Fall back to a plain resolve rather than
119
+ // giving up on the patch entirely.
120
+ return deps.resolve(specifier);
121
+ }
122
+ }
123
+ function tr46Entry(deps) {
124
+ return resolveFromJsdom(deps, 'tr46', 'whatwg-url');
125
+ }
126
+ function cssstyleTargets(deps) {
127
+ const packageRoot = findPackageRoot(deps, resolveFromJsdom(deps, 'cssstyle'), 'cssstyle');
128
+ return [
129
+ join(packageRoot, 'lib', 'allExtraProperties.js'),
130
+ join(packageRoot, 'lib', 'generated', 'allProperties.js'),
131
+ join(packageRoot, 'lib', 'generated', 'implementedProperties.js')
132
+ ].map((file) => ({
133
+ file,
134
+ label: `cssstyle/${relative(packageRoot, file).split(sep).join('/')}`
135
+ }));
136
+ }
137
+ /**
138
+ * Apply both patches if they are missing. Safe to call repeatedly: an already
139
+ * patched tree is a few `readFileSync` calls and no writes. Never throws.
140
+ */
141
+ export function ensureJitiCompat(deps = defaultDeps) {
142
+ const status = { pending: [], patched: [] };
143
+ try {
144
+ const file = tr46Entry(deps);
145
+ const contents = deps.readFileSync(file);
146
+ if (contents.includes(PUNYCODE_SPECIFIER)) {
147
+ deps.writeFileSync(file, contents.replaceAll(PUNYCODE_SPECIFIER, PUNYCODE_REPLACEMENT));
148
+ status.patched.push('tr46/index.js');
149
+ }
150
+ }
151
+ catch {
152
+ // A read-only install, a missing dependency, or a future dependency bump
153
+ // that changes the shape. Report it rather than blocking extension load.
154
+ status.pending.push('tr46/index.js');
155
+ }
156
+ try {
157
+ for (const { file, label } of cssstyleTargets(deps)) {
158
+ // Guard the read too: one unreadable file (EACCES, odd permissions) must
159
+ // not abandon the other two, which may be perfectly writable.
160
+ try {
161
+ if (!deps.existsSync(file))
162
+ continue;
163
+ const contents = deps.readFileSync(file);
164
+ if (contents.includes(SET_SHIM_MARKER))
165
+ continue;
166
+ if (!contents.includes('module.exports = new Set('))
167
+ continue;
168
+ deps.writeFileSync(file, contents + SET_SHIM);
169
+ status.patched.push(label);
170
+ }
171
+ catch {
172
+ status.pending.push(label);
173
+ }
174
+ }
175
+ }
176
+ catch {
177
+ status.pending.push('cssstyle');
178
+ }
179
+ return status;
180
+ }
181
+ /**
182
+ * Read-only view of the same checks, for `/web-agent doctor`. Never throws and
183
+ * never writes.
184
+ */
185
+ export function checkJitiCompat(deps = defaultDeps) {
186
+ const status = { pending: [], patched: [] };
187
+ try {
188
+ const contents = deps.readFileSync(tr46Entry(deps));
189
+ if (contents.includes(PUNYCODE_SPECIFIER))
190
+ status.pending.push('tr46/index.js');
191
+ }
192
+ catch {
193
+ status.pending.push('tr46/index.js');
194
+ }
195
+ try {
196
+ for (const { file, label } of cssstyleTargets(deps)) {
197
+ try {
198
+ if (!deps.existsSync(file))
199
+ continue;
200
+ const contents = deps.readFileSync(file);
201
+ if (contents.includes(SET_SHIM_MARKER))
202
+ continue;
203
+ if (contents.includes('module.exports = new Set('))
204
+ status.pending.push(label);
205
+ }
206
+ catch {
207
+ status.pending.push(label);
208
+ }
209
+ }
210
+ }
211
+ catch {
212
+ status.pending.push('cssstyle');
213
+ }
214
+ return status;
215
+ }
@@ -61,6 +61,10 @@ function serializeBackendConfigOverride(config) {
61
61
  if (config.headless && Object.keys(config.headless).length > 0) {
62
62
  backends.headless = { ...config.headless };
63
63
  }
64
+ if (config.proxy && Object.keys(config.proxy).length > 0) {
65
+ const { password: _password, ...proxy } = config.proxy;
66
+ backends.proxy = { ...proxy };
67
+ }
64
68
  return { backends };
65
69
  }
66
70
  async function readConfigFileForWrite(filePath) {
@@ -5,6 +5,8 @@ type Subtitle = {
5
5
  text: string;
6
6
  };
7
7
  type YoutubeReaderDeps = {
8
+ /** Fetch implementation used for YouTube's requests (defaults to global fetch). */
9
+ fetchImpl?: typeof fetch;
8
10
  fetchSubtitles?: (input: {
9
11
  videoID: string;
10
12
  lang: string;
@@ -17,5 +19,5 @@ type YoutubeReaderDeps = {
17
19
  description?: string;
18
20
  }>;
19
21
  };
20
- export declare function createYoutubeReader({ fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
22
+ export declare function createYoutubeReader({ fetchImpl, fetchSubtitles, fetchDetails }?: YoutubeReaderDeps): SpecialContentReader;
21
23
  export {};
@@ -21,7 +21,15 @@ function extractVideoId(url) {
21
21
  }
22
22
  return undefined;
23
23
  }
24
- export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetails = getVideoDetails } = {}) {
24
+ export function createYoutubeReader({ fetchImpl = fetch, fetchSubtitles, fetchDetails } = {}) {
25
+ // Route YouTube's caption/metadata requests through the configured fetch
26
+ // client (e.g. the proxy) unless an explicit override is provided.
27
+ const doFetchSubtitles = fetchSubtitles ?? ((input) => getSubtitles({ ...input, fetch: fetchImpl }));
28
+ const doFetchDetails = fetchDetails ??
29
+ ((input) => getVideoDetails({ ...input, fetch: fetchImpl }).then((details) => ({
30
+ title: details.title,
31
+ description: details.description
32
+ })));
25
33
  return {
26
34
  name: 'youtube',
27
35
  canHandle(url) {
@@ -39,8 +47,8 @@ export function createYoutubeReader({ fetchSubtitles = getSubtitles, fetchDetail
39
47
  }
40
48
  try {
41
49
  const [subtitles, details] = await Promise.all([
42
- fetchSubtitles({ videoID, lang: 'en' }),
43
- fetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
50
+ doFetchSubtitles({ videoID, lang: 'en' }),
51
+ doFetchDetails({ videoID, lang: 'en' }).catch(() => ({ title: undefined }))
44
52
  ]);
45
53
  if (!subtitles || subtitles.length === 0) {
46
54
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@demigodmode/pi-web-agent",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Pi package for reliable web access with explicit search, fetch, and headless boundaries.",
5
5
  "type": "module",
6
6
  "main": "./dist/extension.js",
@@ -68,6 +68,7 @@
68
68
  "jsdom": "^26.0.0",
69
69
  "playwright": "^1.60.0",
70
70
  "typebox": "^1.1.37",
71
+ "undici": "^8.10.2",
71
72
  "unpdf": "^1.8.1",
72
73
  "youtube-caption-extractor": "^1.10.2"
73
74
  },
@@ -10,19 +10,63 @@
10
10
  // when this package is installed as a pi extension via `pi install npm:...`.
11
11
  // Safe to run multiple times and safe to no-op if the target files or
12
12
  // patterns are missing (e.g. a future dependency bump changes the shape).
13
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
13
+ //
14
+ // src/jiti-compat.ts does the same two patches at extension load time, because
15
+ // the shared ~/.pi/agent/npm tree means another extension's install can revert
16
+ // them long after this hook ran (#34). The logic is deliberately duplicated
17
+ // rather than shared: postinstall runs before `npm run build` in CI and in a
18
+ // fresh clone, so this file cannot depend on dist/. Keep the two in sync.
19
+ import { readFileSync, writeFileSync, existsSync, renameSync, unlinkSync } from "node:fs";
14
20
  import { createRequire } from "node:module";
15
21
  import { dirname, join, relative, sep } from "node:path";
16
22
 
17
- const resolveFromHere = createRequire(import.meta.url).resolve;
23
+ const requireFromHere = createRequire(import.meta.url);
24
+ const resolveFromHere = requireFromHere.resolve;
25
+
26
+ // Resolve the copies jsdom actually loads. The shared ~/.pi/agent/npm tree
27
+ // often holds a second, nested copy of a package when extensions disagree on
28
+ // versions, and patching a hoisted copy jsdom never requires would fix
29
+ // nothing. Mirrors resolveFromJsdom in src/jiti-compat.ts.
30
+ function resolveFromJsdom(specifier, via) {
31
+ try {
32
+ const jsdomEntry = resolveFromHere("jsdom");
33
+ const importer = via ? createRequire(jsdomEntry).resolve(via) : jsdomEntry;
34
+ return createRequire(importer).resolve(specifier);
35
+ } catch {
36
+ return resolveFromHere(specifier);
37
+ }
38
+ }
39
+
40
+ // Temp file + rename, not an in-place write. `writeFileSync` truncates first,
41
+ // so an interrupted write would leave a half-patched file that still contains
42
+ // the marker comment and would be skipped as "already patched" forever.
43
+ // Mirrors writeFileAtomic in src/jiti-compat.ts.
44
+ function writeFileAtomic(path, contents) {
45
+ const temp = `${path}.pi-web-agent-${process.pid}.tmp`;
46
+ try {
47
+ writeFileSync(temp, contents);
48
+ renameSync(temp, path);
49
+ } catch (err) {
50
+ try {
51
+ if (existsSync(temp)) unlinkSync(temp);
52
+ } catch {
53
+ // Best effort.
54
+ }
55
+ throw err;
56
+ }
57
+ }
18
58
 
19
59
  function findPackageRoot(entryFile, packageName) {
20
60
  let directory = dirname(entryFile);
21
61
  while (true) {
22
62
  const manifestFile = join(directory, "package.json");
23
63
  if (existsSync(manifestFile)) {
24
- const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
25
- if (manifest.name === packageName) return directory;
64
+ try {
65
+ const manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
66
+ if (manifest.name === packageName) return directory;
67
+ } catch {
68
+ // Malformed package.json on the way up. Keep walking.
69
+ }
26
70
  }
27
71
 
28
72
  const parent = dirname(directory);
@@ -34,7 +78,7 @@ function findPackageRoot(entryFile, packageName) {
34
78
  }
35
79
 
36
80
  function patchTr46() {
37
- const file = resolveFromHere("tr46");
81
+ const file = resolveFromJsdom("tr46", "whatwg-url");
38
82
  if (!existsSync(file)) {
39
83
  console.debug("patch-jiti-compat: tr46/index.js not found, skipping");
40
84
  return;
@@ -44,7 +88,7 @@ function patchTr46() {
44
88
  console.debug("patch-jiti-compat: tr46/index.js does not match expected pattern, skipping");
45
89
  return;
46
90
  }
47
- writeFileSync(
91
+ writeFileAtomic(
48
92
  file,
49
93
  contents.replaceAll('require("punycode/")', 'require("punycode/punycode.js")'),
50
94
  );
@@ -61,7 +105,7 @@ module.exports[Symbol.iterator] = Set.prototype[Symbol.iterator].bind(module.exp
61
105
  `;
62
106
 
63
107
  function patchCssstyleSetExports() {
64
- const packageRoot = findPackageRoot(resolveFromHere("cssstyle"), "cssstyle");
108
+ const packageRoot = findPackageRoot(resolveFromJsdom("cssstyle"), "cssstyle");
65
109
  const files = [
66
110
  join(packageRoot, "lib", "allExtraProperties.js"),
67
111
  join(packageRoot, "lib", "generated", "allProperties.js"),
@@ -79,7 +123,7 @@ function patchCssstyleSetExports() {
79
123
  console.debug(`patch-jiti-compat: ${label} does not match expected pattern, skipping`);
80
124
  continue;
81
125
  }
82
- writeFileSync(file, contents + SET_SHIM);
126
+ writeFileAtomic(file, contents + SET_SHIM);
83
127
  console.log(`patch-jiti-compat: patched ${label}`);
84
128
  }
85
129
  }