@taurgis/bonsai 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,7 @@ import { evaluateFreshness, checkMaxAgeExpired, durationFlagError, } from '../li
10
10
  import { revalidateCache, createArtifactFromFetch } from '../lib/research/revalidate.js';
11
11
  import { fetchStaticHtml, fetchText } from '../lib/research/fetcher.js';
12
12
  import { PROXY_TUNNEL_REJECTION_PATTERN } from '../lib/research/proxy.js';
13
- import { fetchRenderedHtml } from '../lib/research/browser.js';
13
+ import { fetchRenderedHtml, SANDBOX_EGRESS_ERROR_MARKER } from '../lib/research/browser.js';
14
14
  import { capturePage } from '../lib/research/capture.js';
15
15
  import { persistSectionArtifacts } from '../lib/research/docs/section-artifacts.js';
16
16
  import { applyAutoTags } from '../lib/research/keywords.js';
@@ -387,6 +387,12 @@ export function fetchFailureGuidance(message, url) {
387
387
  suggestions: ['Check the hostname is spelled correctly and resolves on a public network.'],
388
388
  };
389
389
  }
390
+ // Chrome's own network stack hit a proxy-enforced sandbox egress block (browser.ts's
391
+ // describeNavigationFailure already explains the cause and the fix); only a workaround and the
392
+ // docs ref are added here.
393
+ if (message.includes(SANDBOX_EGRESS_ERROR_MARKER)) {
394
+ return { suggestions: [importHint], ref };
395
+ }
390
396
  // A configured proxy (HTTPS_PROXY/HTTP_PROXY) refused to tunnel to this host — distinct from
391
397
  // the destination's own 401/403 above, which is the site itself rejecting the request. Shared
392
398
  // with fetcher.ts's retry-fallback check so the two stay in sync by construction.
@@ -76,4 +76,17 @@ export declare class ResponseCapture {
76
76
  /** Resolves with the response body text once a matching response finishes, or null on timeout. */
77
77
  waitFor(key: string, timeoutMs: number): Promise<string | null>;
78
78
  }
79
+ export declare const SANDBOX_EGRESS_ERROR_MARKER = "sandboxed execution environment";
80
+ /**
81
+ * Builds the error thrown for a failed Page.navigate. When a proxy is configured (proxy.ts's
82
+ * signal for "this is a sandboxed execution environment") and Chrome's failure code indicates the
83
+ * proxy itself declined the tunnel, the destination isn't reachable because of the sandbox's
84
+ * network egress gateway rejecting it, not a real navigation problem — so the message says that
85
+ * explicitly, rather than surfacing a bare Chrome net-error code that looks like a generic
86
+ * connectivity failure. It deliberately doesn't claim the fix is "ask an admin to allowlist this
87
+ * host": the gateway that rejects the CONNECT tunnel can disagree with an org's configured domain
88
+ * policy (a different layer may not reach it), so the safer, always-correct advice is to verify
89
+ * against the environment directly and fall back to a manual fetch if it's still unreachable.
90
+ */
91
+ export declare function describeNavigationFailure(errorText: string, url: string): Error;
79
92
  export declare function fetchRenderedHtml(url: string, options?: BrowserFetchOptions): Promise<BrowserFetchResult>;
@@ -1,8 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
+ import { join } from 'node:path';
4
5
  import { checkDnsSafety } from './fetcher.js';
5
- import { getChromeProxyArgs } from './proxy.js';
6
+ import { getChromeProxyArgs, getChromeSpkiArgs, getChromeTlsCompatibilityArgs, isProxyConfigured, } from './proxy.js';
6
7
  import { normalizeUrl } from './url.js';
7
8
  export class CdpClient {
8
9
  ws;
@@ -65,6 +66,13 @@ export function findChromePath() {
65
66
  return process.env.CHROME_PATH;
66
67
  }
67
68
  const paths = [];
69
+ // Claude Code's remote sandbox (and other Playwright-provisioned environments) pre-installs a
70
+ // Chromium build under PLAYWRIGHT_BROWSERS_PATH rather than a system package manager location,
71
+ // with a `chromium` symlink at its root pointing at the actual browser binary. It's headless-Chrome
72
+ // capable (needed for the CDP `--headless=new` flag this module uses), so it's checked first.
73
+ if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
74
+ paths.push(join(process.env.PLAYWRIGHT_BROWSERS_PATH, 'chromium'));
75
+ }
68
76
  if (process.platform === 'darwin') {
69
77
  paths.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium');
70
78
  }
@@ -108,6 +116,15 @@ export function buildChromeArgs() {
108
116
  // CDP navigation uses Chrome's own network stack, not Node's fetch/undici, so a sandbox
109
117
  // proxy detected via *_PROXY env vars must be passed to Chrome separately (see proxy.ts).
110
118
  ...getChromeProxyArgs(),
119
+ // The sandbox proxy above re-terminates TLS with its own CA, which Chrome's Chrome-Root-Store
120
+ // verifier doesn't trust by default (unlike Node/curl/Python, which read NODE_EXTRA_CA_CERTS/
121
+ // SSL_CERT_FILE/CURL_CA_BUNDLE). Pin trust to that specific CA rather than disabling
122
+ // verification wholesale (see proxy.ts's getChromeSpkiArgs).
123
+ ...getChromeSpkiArgs(),
124
+ // Separately from cert trust: some sandbox proxies' TLS terminator never responds to Chrome's
125
+ // default TLS 1.3 ClientHello and the connection is eventually reset (see proxy.ts's
126
+ // getChromeTlsCompatibilityArgs for how this was diagnosed).
127
+ ...getChromeTlsCompatibilityArgs(),
111
128
  ];
112
129
  }
113
130
  async function spawnChrome(chromePath) {
@@ -303,6 +320,47 @@ export class ResponseCapture {
303
320
  });
304
321
  }
305
322
  }
323
+ // Chrome has its own network stack (see getChromeProxyArgs), so when a sandbox's egress-enforcing
324
+ // proxy declines to tunnel to a destination, Chrome surfaces one of these net-error codes instead
325
+ // of the HTTP-level rejection undici sees in fetcher.ts (PROXY_TUNNEL_REJECTION_PATTERN). Matched
326
+ // against Page.navigate's `errorText`, which arrives as e.g. "net::ERR_TUNNEL_CONNECTION_FAILED".
327
+ const PROXY_BLOCKED_CHROME_NET_ERRORS = [
328
+ 'ERR_TUNNEL_CONNECTION_FAILED',
329
+ 'ERR_PROXY_CONNECTION_FAILED',
330
+ 'ERR_SOCKS_CONNECTION_FAILED',
331
+ ];
332
+ // Shared substring fetch.ts's fetchFailureGuidance matches on to attach suggestions/ref to this
333
+ // error, without duplicating the message text it needs to match against.
334
+ export const SANDBOX_EGRESS_ERROR_MARKER = 'sandboxed execution environment';
335
+ /**
336
+ * Builds the error thrown for a failed Page.navigate. When a proxy is configured (proxy.ts's
337
+ * signal for "this is a sandboxed execution environment") and Chrome's failure code indicates the
338
+ * proxy itself declined the tunnel, the destination isn't reachable because of the sandbox's
339
+ * network egress gateway rejecting it, not a real navigation problem — so the message says that
340
+ * explicitly, rather than surfacing a bare Chrome net-error code that looks like a generic
341
+ * connectivity failure. It deliberately doesn't claim the fix is "ask an admin to allowlist this
342
+ * host": the gateway that rejects the CONNECT tunnel can disagree with an org's configured domain
343
+ * policy (a different layer may not reach it), so the safer, always-correct advice is to verify
344
+ * against the environment directly and fall back to a manual fetch if it's still unreachable.
345
+ */
346
+ export function describeNavigationFailure(errorText, url) {
347
+ const isProxyBlock = isProxyConfigured() && PROXY_BLOCKED_CHROME_NET_ERRORS.some((code) => errorText.includes(code));
348
+ if (!isProxyBlock) {
349
+ return new Error(`Navigation failed: ${errorText}`);
350
+ }
351
+ let host = url;
352
+ try {
353
+ host = new URL(url).hostname;
354
+ }
355
+ catch { }
356
+ return new Error(`Chrome could not reach "${host}" (${errorText}). This looks like a ${SANDBOX_EGRESS_ERROR_MARKER} ` +
357
+ `(e.g. Claude Code's remote sandbox) whose outbound network gateway rejected the connection to ` +
358
+ `this host, via the configured proxy (HTTPS_PROXY/HTTP_PROXY). This can happen even when the ` +
359
+ `environment's domain policy looks unrestricted, since the gateway enforcing egress can be a ` +
360
+ `separate layer from that policy. It can't be worked around from inside the sandbox by retrying ` +
361
+ `or bypassing the proxy: confirm "${host}" is reachable from this environment (e.g. test the ` +
362
+ `proxy directly), or fetch the page from a network with access and import it instead.`);
363
+ }
306
364
  // Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
307
365
  const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
308
366
  function isTransientBrowserNavigationError(err) {
@@ -339,7 +397,7 @@ async function fetchRenderedHtmlOnce(url, options = {}) {
339
397
  // A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
340
398
  // fail fast here instead of waiting out the full navigation timeout on a dead page.
341
399
  if (nav.errorText) {
342
- throw new Error(`Navigation failed: ${nav.errorText}`);
400
+ throw describeNavigationFailure(nav.errorText, currentUrl);
343
401
  }
344
402
  await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
345
403
  const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
@@ -33,3 +33,39 @@ export declare function getProxyDispatcher(): Dispatcher | undefined;
33
33
  * be set, silently disagreeing with how fetch() routes the same traffic.
34
34
  */
35
35
  export declare function getChromeProxyArgs(): string[];
36
+ /**
37
+ * Caps Chrome's TLS version at 1.2 for connections made through a detected sandbox proxy, or []
38
+ * when no proxy is configured. Some sandbox proxies' TLS-terminating layer never responds to
39
+ * Chrome's default TLS 1.3 ClientHello (BoringSSL sends ECH-GREASE by default, which most tools'
40
+ * TLS stacks — curl/OpenSSL's included — don't add): captured via --log-net-log, Chrome sends its
41
+ * ClientHello, the proxy sends nothing back, and ~6s later the socket read fails with
42
+ * net::ERR_CONNECTION_RESET. Capping to TLS 1.2 avoids that ClientHello shape entirely. Scoped to
43
+ * the proxied path only (via isProxyConfigured, the same signal every other sandbox-specific flag
44
+ * in this module uses) so ordinary developer machines get Chrome's normal TLS 1.3 negotiation
45
+ * unchanged; this doesn't affect certificate verification (see getChromeSpkiArgs), only which
46
+ * protocol version Chrome offers.
47
+ */
48
+ export declare function getChromeTlsCompatibilityArgs(): string[];
49
+ export declare const CA_BUNDLE_ENV_VARS: string[];
50
+ /**
51
+ * Base64-encoded SHA-256 SPKI fingerprints (RFC 7469 §2.4) for every certificate in the sandbox's
52
+ * discoverable CA bundle, in the exact encoding Chromium's --ignore-certificate-errors-spki-list
53
+ * expects — equivalent to `openssl x509 -pubkey -noout | openssl pkey -pubin -outform der |
54
+ * openssl dgst -sha256 -binary | openssl enc -base64` for each cert. Returns [] when no bundle is
55
+ * discoverable or none of its entries parse, so a missing/malformed bundle degrades to Chrome's
56
+ * normal (unpinned) verification rather than breaking the browser launch.
57
+ */
58
+ export declare function getCaBundleSpkiHashes(): string[];
59
+ /**
60
+ * Chrome CLI flag that trusts exactly the sandbox's injected CA(s), pinned by SPKI hash rather
61
+ * than by disabling verification. Chromium's TLS stack (the Chrome Root Store) never reads
62
+ * NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so a sandbox that
63
+ * MITMs HTTPS through its proxy (see isProxyConfigured) makes every other tool trust the proxy's
64
+ * cert while Chrome's handshake with it fails with ERR_CERT_AUTHORITY_INVALID. This is scoped
65
+ * narrowly to the specific CA(s) discovered via the CA bundle env vars — never
66
+ * --ignore-certificate-errors, which would disable verification for every host, including ones
67
+ * that have nothing to do with the sandbox's proxy. A no-op (empty array) when no proxy is
68
+ * configured or no CA bundle is discoverable, so default Chrome trust is untouched on ordinary
69
+ * developer machines.
70
+ */
71
+ export declare function getChromeSpkiArgs(): string[];
@@ -1,3 +1,5 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createHash, X509Certificate } from 'node:crypto';
1
3
  import { EnvHttpProxyAgent } from 'undici';
2
4
  // Order matters: undici's own EnvHttpProxyAgent (github.com/nodejs/undici lib/dispatcher/
3
5
  // env-http-proxy-agent.js) prefers the lowercase variant of each var over its uppercase twin
@@ -105,3 +107,82 @@ export function getChromeProxyArgs() {
105
107
  args.push(`--proxy-bypass-list=${toChromeBypassList(noProxy)}`);
106
108
  return args;
107
109
  }
110
+ /**
111
+ * Caps Chrome's TLS version at 1.2 for connections made through a detected sandbox proxy, or []
112
+ * when no proxy is configured. Some sandbox proxies' TLS-terminating layer never responds to
113
+ * Chrome's default TLS 1.3 ClientHello (BoringSSL sends ECH-GREASE by default, which most tools'
114
+ * TLS stacks — curl/OpenSSL's included — don't add): captured via --log-net-log, Chrome sends its
115
+ * ClientHello, the proxy sends nothing back, and ~6s later the socket read fails with
116
+ * net::ERR_CONNECTION_RESET. Capping to TLS 1.2 avoids that ClientHello shape entirely. Scoped to
117
+ * the proxied path only (via isProxyConfigured, the same signal every other sandbox-specific flag
118
+ * in this module uses) so ordinary developer machines get Chrome's normal TLS 1.3 negotiation
119
+ * unchanged; this doesn't affect certificate verification (see getChromeSpkiArgs), only which
120
+ * protocol version Chrome offers.
121
+ */
122
+ export function getChromeTlsCompatibilityArgs() {
123
+ return isProxyConfigured() ? ['--ssl-version-max=tls1.2'] : [];
124
+ }
125
+ // Order matters for the same reason as the *_PROXY lists above: read consistently so this module
126
+ // and every caller agree on which file is "the" CA bundle. Node itself only honors
127
+ // NODE_EXTRA_CA_CERTS; SSL_CERT_FILE and CURL_CA_BUNDLE are the conventions OpenSSL-linked tools
128
+ // (curl, Python, etc.) use instead. A sandbox that MITMs TLS to route through its proxy sets all
129
+ // three to the same bundle so every tool picks it up regardless of which convention it follows.
130
+ export const CA_BUNDLE_ENV_VARS = ['NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'CURL_CA_BUNDLE'];
131
+ function findCaBundlePath() {
132
+ const path = firstEnv(CA_BUNDLE_ENV_VARS);
133
+ return path && existsSync(path) ? path : undefined;
134
+ }
135
+ function extractPemCertificates(bundle) {
136
+ return bundle.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? [];
137
+ }
138
+ /**
139
+ * Base64-encoded SHA-256 SPKI fingerprints (RFC 7469 §2.4) for every certificate in the sandbox's
140
+ * discoverable CA bundle, in the exact encoding Chromium's --ignore-certificate-errors-spki-list
141
+ * expects — equivalent to `openssl x509 -pubkey -noout | openssl pkey -pubin -outform der |
142
+ * openssl dgst -sha256 -binary | openssl enc -base64` for each cert. Returns [] when no bundle is
143
+ * discoverable or none of its entries parse, so a missing/malformed bundle degrades to Chrome's
144
+ * normal (unpinned) verification rather than breaking the browser launch.
145
+ */
146
+ export function getCaBundleSpkiHashes() {
147
+ const bundlePath = findCaBundlePath();
148
+ if (!bundlePath)
149
+ return [];
150
+ let bundle;
151
+ try {
152
+ bundle = readFileSync(bundlePath, 'utf8');
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ const hashes = new Set();
158
+ for (const pem of extractPemCertificates(bundle)) {
159
+ try {
160
+ const spkiDer = new X509Certificate(pem).publicKey.export({ type: 'spki', format: 'der' });
161
+ hashes.add(createHash('sha256').update(spkiDer).digest('base64'));
162
+ }
163
+ catch {
164
+ // Skip entries that don't parse as certificates rather than failing the whole bundle.
165
+ }
166
+ }
167
+ return Array.from(hashes);
168
+ }
169
+ /**
170
+ * Chrome CLI flag that trusts exactly the sandbox's injected CA(s), pinned by SPKI hash rather
171
+ * than by disabling verification. Chromium's TLS stack (the Chrome Root Store) never reads
172
+ * NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so a sandbox that
173
+ * MITMs HTTPS through its proxy (see isProxyConfigured) makes every other tool trust the proxy's
174
+ * cert while Chrome's handshake with it fails with ERR_CERT_AUTHORITY_INVALID. This is scoped
175
+ * narrowly to the specific CA(s) discovered via the CA bundle env vars — never
176
+ * --ignore-certificate-errors, which would disable verification for every host, including ones
177
+ * that have nothing to do with the sandbox's proxy. A no-op (empty array) when no proxy is
178
+ * configured or no CA bundle is discoverable, so default Chrome trust is untouched on ordinary
179
+ * developer machines.
180
+ */
181
+ export function getChromeSpkiArgs() {
182
+ if (!isProxyConfigured())
183
+ return [];
184
+ const hashes = getCaBundleSpkiHashes();
185
+ if (!hashes.length)
186
+ return [];
187
+ return [`--ignore-certificate-errors-spki-list=${hashes.join(',')}`];
188
+ }
@@ -1,4 +1,4 @@
1
- import { openCdpPage, waitForLoad, ResponseCapture, } from '../lib/research/browser.js';
1
+ import { openCdpPage, waitForLoad, ResponseCapture, describeNavigationFailure, } from '../lib/research/browser.js';
2
2
  import { checkDnsSafety } from '../lib/research/fetcher.js';
3
3
  import { htmlToMarkdown } from '../lib/research/markdown.js';
4
4
  import { contentMetrics, contentRicherThan } from '../lib/research/regression.js';
@@ -44,6 +44,10 @@ const BASE_REMOVE_SELECTORS = [
44
44
  'dx-scroll-manager',
45
45
  'dx-skip-nav-link',
46
46
  'dw-instrumentation',
47
+ // The left-hand TOC/search drawer nested inside doc-content-layout's shadow DOM. It renders the
48
+ // entire site's navigation tree (hundreds of <a> links) as a sibling of the real article body,
49
+ // so left unremoved it dwarfs and precedes the actual content in the captured Markdown.
50
+ 'dx-sidebar-old',
47
51
  '[role="navigation"]',
48
52
  '[aria-label*="Table of Contents" i]',
49
53
  '#onetrust-consent-sdk',
@@ -279,7 +283,15 @@ async function captureSalesforceAttempt(page, url, captureScript, contentSelecto
279
283
  accept: (body) => parseDocsApiPayload(body) !== null,
280
284
  },
281
285
  ]);
282
- await page.client.send('Page.navigate', { url }, page.sessionId);
286
+ // A network-level failure (DNS, refused/closed connection, TLS, a proxy declining to tunnel) never
287
+ // fires a load event and renders Chrome's own "site can't be reached" interstitial instead. Without
288
+ // this check that interstitial sails through pollSalesforceContentReady/assertReadableSalesforceContent
289
+ // as if it were real article content — it's plausible-length HTML that isn't a 404/loading shell — so
290
+ // the fetch would silently "succeed" with Chrome's error page cached as the documentation.
291
+ const nav = await page.client.send('Page.navigate', { url }, page.sessionId);
292
+ if (nav.errorText) {
293
+ throw describeNavigationFailure(nav.errorText, url);
294
+ }
283
295
  await waitForLoad(page.client, page.sessionId, TIMEOUT_MS, 0).catch(() => { });
284
296
  await page.client
285
297
  .send('Runtime.evaluate', { expression: ACCEPT_CONSENT_EXPRESSION }, page.sessionId)
@@ -843,5 +843,5 @@
843
843
  "stdoutIsPrimaryData": true
844
844
  }
845
845
  },
846
- "version": "3.0.1"
846
+ "version": "3.0.3"
847
847
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taurgis/bonsai",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Bonsai is a standalone local research cache CLI for AI agents",
5
5
  "repository": {
6
6
  "type": "git",