@taurgis/bonsai 1.0.6 → 3.0.1

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 (127) hide show
  1. package/README.md +7 -13
  2. package/agents/agents/official-docs-researcher.agent.md +15 -35
  3. package/agents/agents/salesforce-docs-researcher.agent.md +17 -44
  4. package/agents/hooks/README.md +2 -9
  5. package/agents/hooks/claude-code/hooks/bonsai-web-fetch.sh +1 -1
  6. package/agents/hooks/codex/hooks/bonsai-user-prompt-context.sh +1 -1
  7. package/agents/hooks/codex/hooks/bonsai-web-fetch.sh +1 -1
  8. package/agents/hooks/cursor/hooks/bonsai-web-fetch.sh +1 -1
  9. package/agents/hooks/github-copilot/bonsai-web-fetch.sh +1 -1
  10. package/agents/instructions/salesforce-research.instructions.md +7 -8
  11. package/agents/instructions/web-research.instructions.md +3 -4
  12. package/agents/skills/web-research/SKILL.md +24 -37
  13. package/bin/cli.mjs +15 -2
  14. package/dist/base-command.d.ts +15 -2
  15. package/dist/base-command.js +32 -4
  16. package/dist/commands/fetch.d.ts +3 -0
  17. package/dist/commands/fetch.js +96 -30
  18. package/dist/commands/import.js +14 -5
  19. package/dist/commands/inspect.d.ts +4 -0
  20. package/dist/commands/inspect.js +48 -24
  21. package/dist/commands/list.d.ts +3 -1
  22. package/dist/commands/list.js +42 -25
  23. package/dist/commands/prune.d.ts +1 -0
  24. package/dist/commands/prune.js +35 -9
  25. package/dist/commands/status.d.ts +2 -0
  26. package/dist/commands/status.js +57 -25
  27. package/dist/commands.d.ts +0 -2
  28. package/dist/commands.js +0 -2
  29. package/dist/hooks/command-not-found/suggest.d.ts +7 -1
  30. package/dist/hooks/command-not-found/suggest.js +82 -57
  31. package/dist/lib/argv.js +51 -7
  32. package/dist/lib/color.d.ts +20 -0
  33. package/dist/lib/color.js +36 -0
  34. package/dist/lib/envelope.js +8 -2
  35. package/dist/lib/help-preflight.d.ts +15 -0
  36. package/dist/lib/help-preflight.js +81 -0
  37. package/dist/lib/limit-flag.d.ts +1 -0
  38. package/dist/lib/limit-flag.js +24 -0
  39. package/dist/lib/research/{search-index.d.ts → artifact-index.d.ts} +5 -5
  40. package/dist/lib/research/{search-index.js → artifact-index.js} +10 -10
  41. package/dist/lib/research/browser.d.ts +1 -8
  42. package/dist/lib/research/browser.js +32 -46
  43. package/dist/lib/research/docs/markdown-source.js +2 -1
  44. package/dist/lib/research/extract.js +2 -1
  45. package/dist/lib/research/fetcher.js +92 -48
  46. package/dist/lib/research/prompt-injection.d.ts +1 -0
  47. package/dist/lib/research/prompt-injection.js +149 -0
  48. package/dist/lib/research/proxy.d.ts +35 -0
  49. package/dist/lib/research/proxy.js +107 -0
  50. package/dist/lib/research/regression.d.ts +18 -0
  51. package/dist/lib/research/regression.js +47 -0
  52. package/dist/lib/research/storage.d.ts +0 -1
  53. package/dist/lib/research/storage.js +15 -13
  54. package/dist/lib/research/url.d.ts +22 -0
  55. package/dist/lib/research/url.js +63 -1
  56. package/dist/lib/text.d.ts +3 -1
  57. package/dist/lib/text.js +9 -1
  58. package/dist/sites/coveo.d.ts +7 -0
  59. package/dist/sites/coveo.js +33 -0
  60. package/dist/sites/index.js +1 -18
  61. package/dist/sites/salesforce/fetch-page.js +1 -1
  62. package/dist/sites/salesforce/index.js +3 -8
  63. package/dist/sites/salesforce-developer/fetch-page.js +3 -1
  64. package/dist/sites/salesforce-developer/index.js +1 -2
  65. package/dist/sites/salesforce-doc-fetch.d.ts +11 -0
  66. package/dist/sites/salesforce-doc-fetch.js +117 -76
  67. package/dist/sites/salesforce-dom-probe.d.ts +16 -0
  68. package/dist/sites/salesforce-dom-probe.js +114 -0
  69. package/dist/sites/types.d.ts +0 -6
  70. package/oclif.manifest.json +31 -106
  71. package/package.json +35 -26
  72. package/dist/commands/index.d.ts +0 -35
  73. package/dist/commands/index.js +0 -306
  74. package/dist/commands/research/config/base.d.ts +0 -23
  75. package/dist/commands/research/config/base.js +0 -46
  76. package/dist/commands/research/config/get.d.ts +0 -20
  77. package/dist/commands/research/config/get.js +0 -45
  78. package/dist/commands/research/config/list.d.ts +0 -17
  79. package/dist/commands/research/config/list.js +0 -42
  80. package/dist/commands/research/config/set.d.ts +0 -31
  81. package/dist/commands/research/config/set.js +0 -94
  82. package/dist/commands/research/config/unset.d.ts +0 -21
  83. package/dist/commands/research/config/unset.js +0 -59
  84. package/dist/commands/research/import.d.ts +0 -38
  85. package/dist/commands/research/import.js +0 -313
  86. package/dist/commands/research/inspect.d.ts +0 -13
  87. package/dist/commands/research/inspect.js +0 -86
  88. package/dist/commands/research/list.d.ts +0 -25
  89. package/dist/commands/research/list.js +0 -143
  90. package/dist/commands/research/prune.d.ts +0 -24
  91. package/dist/commands/research/prune.js +0 -141
  92. package/dist/commands/research/search.d.ts +0 -32
  93. package/dist/commands/research/search.js +0 -334
  94. package/dist/commands/research/status.d.ts +0 -17
  95. package/dist/commands/research/status.js +0 -108
  96. package/dist/commands/research.d.ts +0 -36
  97. package/dist/commands/research.js +0 -304
  98. package/dist/commands/search.d.ts +0 -35
  99. package/dist/commands/search.js +0 -391
  100. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.d.ts +0 -1
  101. package/dist/lib/research/docs/__fixtures__/sphinx-searchindex.js +0 -2
  102. package/dist/lib/research/docs/discovery-index.d.ts +0 -14
  103. package/dist/lib/research/docs/discovery-index.js +0 -112
  104. package/dist/lib/research/docs/remote-search-runner.d.ts +0 -21
  105. package/dist/lib/research/docs/remote-search-runner.js +0 -114
  106. package/dist/lib/research/docs/remote-search.d.ts +0 -14
  107. package/dist/lib/research/docs/remote-search.js +0 -101
  108. package/dist/lib/research/docs/search-index.d.ts +0 -13
  109. package/dist/lib/research/docs/search-index.js +0 -176
  110. package/dist/sites/algolia.d.ts +0 -9
  111. package/dist/sites/algolia.js +0 -26
  112. package/dist/sites/auto.d.ts +0 -8
  113. package/dist/sites/auto.js +0 -30
  114. package/dist/sites/nextjs/index.d.ts +0 -2
  115. package/dist/sites/nextjs/index.js +0 -52
  116. package/dist/sites/react/index.d.ts +0 -2
  117. package/dist/sites/react/index.js +0 -17
  118. package/dist/sites/salesforce/browser-search.d.ts +0 -7
  119. package/dist/sites/salesforce/browser-search.js +0 -82
  120. package/dist/sites/salesforce/coveo.d.ts +0 -23
  121. package/dist/sites/salesforce/coveo.js +0 -161
  122. package/dist/sites/salesforce/token.d.ts +0 -26
  123. package/dist/sites/salesforce/token.js +0 -154
  124. package/dist/sites/tailwind/index.d.ts +0 -2
  125. package/dist/sites/tailwind/index.js +0 -17
  126. package/dist/sites/vue/index.d.ts +0 -2
  127. package/dist/sites/vue/index.js +0 -17
@@ -0,0 +1,81 @@
1
+ import { Config } from '@oclif/core';
2
+ import { buildEnvelope } from './envelope.js';
3
+ import { buildCommandNotFoundDetails } from '../hooks/command-not-found/suggest.js';
4
+ function commandId(parts) {
5
+ return parts.join(':');
6
+ }
7
+ function positionalTokens(argv) {
8
+ const tokens = [];
9
+ for (const arg of argv) {
10
+ if (arg === '--')
11
+ break;
12
+ if (arg === '--help' || arg === '--json')
13
+ continue;
14
+ if (arg.startsWith('-'))
15
+ continue;
16
+ tokens.push(arg);
17
+ }
18
+ return tokens;
19
+ }
20
+ function commandPrefixLength(tokens, config) {
21
+ let longest = 0;
22
+ for (let n = 1; n <= tokens.length; n++) {
23
+ if (config.commandIDs.includes(commandId(tokens.slice(0, n))))
24
+ longest = n;
25
+ }
26
+ return longest;
27
+ }
28
+ function branchPrefixLength(tokens, config) {
29
+ let longest = 0;
30
+ for (let n = 1; n <= tokens.length; n++) {
31
+ const id = commandId(tokens.slice(0, n));
32
+ if (config.commandIDs.some((command) => command.startsWith(`${id}:`)))
33
+ longest = n;
34
+ }
35
+ return longest;
36
+ }
37
+ function hasValidHelpTarget(tokens, config) {
38
+ const commandLength = commandPrefixLength(tokens, config);
39
+ const branchLength = branchPrefixLength(tokens, config);
40
+ if (commandLength === tokens.length || branchLength === tokens.length)
41
+ return true;
42
+ if (branchLength > 0 && tokens.length > branchLength) {
43
+ return commandLength > branchLength;
44
+ }
45
+ return commandLength > 0;
46
+ }
47
+ /**
48
+ * oclif resolves help before its command_not_found hook, so `bonsai config gett --help` otherwise
49
+ * falls back to a terse framework error. Catch that narrow case and reuse the normal typo guidance.
50
+ */
51
+ export async function tryUnknownHelpOutput(argv, root) {
52
+ if (!argv.includes('--help'))
53
+ return null;
54
+ const tokens = positionalTokens(argv);
55
+ if (tokens.length === 0)
56
+ return null;
57
+ const config = await Config.load({ root });
58
+ if (hasValidHelpTarget(tokens, config))
59
+ return null;
60
+ const attemptedId = commandId(tokens);
61
+ const details = buildCommandNotFoundDetails(attemptedId, [...argv], config);
62
+ if (argv.includes('--json')) {
63
+ return {
64
+ kind: 'json',
65
+ exitCode: 2,
66
+ envelope: buildEnvelope({
67
+ command: details.command,
68
+ ok: false,
69
+ exitCode: 2,
70
+ stderr: `${details.message}\nCode: ${details.code}`,
71
+ data: null,
72
+ code: details.code,
73
+ }),
74
+ };
75
+ }
76
+ return {
77
+ kind: 'human',
78
+ exitCode: 2,
79
+ message: `${details.message}\nCode: ${details.code}`,
80
+ };
81
+ }
@@ -0,0 +1 @@
1
+ export declare function limitFlag(max: number, defaultValue: number, description: string): import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
@@ -0,0 +1,24 @@
1
+ import { Errors, Flags } from '@oclif/core';
2
+ export function limitFlag(max, defaultValue, description) {
3
+ return Flags.custom({
4
+ parse: async (input) => {
5
+ if (!/^\d+$/.test(input)) {
6
+ throw new Errors.CLIError(`Limit must be an integer between 1 and ${max}.`, {
7
+ exit: 2,
8
+ code: 'INVALID_LIMIT',
9
+ });
10
+ }
11
+ const value = Number(input);
12
+ if (value < 1 || value > max) {
13
+ throw new Errors.CLIError(`Limit must be between 1 and ${max}.`, {
14
+ exit: 2,
15
+ code: 'INVALID_LIMIT',
16
+ });
17
+ }
18
+ return value;
19
+ },
20
+ })({
21
+ default: defaultValue,
22
+ description,
23
+ });
24
+ }
@@ -1,18 +1,18 @@
1
1
  import type { ResearchArtifact } from './schema.js';
2
- export interface SearchableArtifact {
2
+ export interface IndexedArtifact {
3
3
  artifact: ResearchArtifact;
4
4
  filePath: string;
5
5
  }
6
6
  /**
7
- * Returns the searchable view of every active artifact in one research dir, using the sidecar index
7
+ * Returns the indexed view of every active artifact in one research dir, using the sidecar index
8
8
  * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
9
- * index (best-effort; a write failure never breaks search). Corrupt/unreadable files are skipped —
9
+ * index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
10
10
  * the real read paths in storage.ts handle archiving them.
11
11
  */
12
- export declare function loadSearchableArtifactsForDir(researchDir: string): SearchableArtifact[];
12
+ export declare function loadIndexedArtifactsForDir(researchDir: string): IndexedArtifact[];
13
13
  /**
14
14
  * Multi-root variant: scans the `research/` dir of each data dir in order and deduplicates by cache
15
15
  * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
16
16
  * {@link scanCacheDirs} but reads through the per-root sidecar index.
17
17
  */
18
- export declare function loadSearchableArtifacts(dataDirs: string[]): SearchableArtifact[];
18
+ export declare function loadIndexedArtifacts(dataDirs: string[]): IndexedArtifact[];
@@ -2,11 +2,11 @@ import { join } from 'node:path';
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
3
  import { atomicWriteFile } from '../atomic-write.js';
4
4
  import { parseArtifactShallow } from './artifact.js';
5
- import { isResearchFile } from './storage.js';
5
+ import { isResearchFile } from './url.js';
6
6
  // Sidecar index that caches the lightweight (metadata + summary + compressed) view of each artifact
7
- // so repeat searches avoid re-reading and re-parsing every file. It is a derived cache only: a stale
8
- // or missing index never changes results, because each entry is validated against the file's current
9
- // signature and re-read on any mismatch.
7
+ // so repeat list/scan operations avoid re-reading and re-parsing every file. It is a derived cache
8
+ // only: a stale or missing index never changes results, because each entry is validated against the
9
+ // file's current signature and re-read on any mismatch.
10
10
  const INDEX_FILE = '.search-index.json';
11
11
  const INDEX_VERSION = 1;
12
12
  // The repo writes artifacts via temp-file + rename, so every rewrite yields a new inode. Combining
@@ -48,12 +48,12 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
48
48
  }
49
49
  }
50
50
  /**
51
- * Returns the searchable view of every active artifact in one research dir, using the sidecar index
51
+ * Returns the indexed view of every active artifact in one research dir, using the sidecar index
52
52
  * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
53
- * index (best-effort; a write failure never breaks search). Corrupt/unreadable files are skipped —
53
+ * index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
54
54
  * the real read paths in storage.ts handle archiving them.
55
55
  */
56
- export function loadSearchableArtifactsForDir(researchDir) {
56
+ export function loadIndexedArtifactsForDir(researchDir) {
57
57
  if (!existsSync(researchDir))
58
58
  return [];
59
59
  const indexPath = join(researchDir, INDEX_FILE);
@@ -83,7 +83,7 @@ export function loadSearchableArtifactsForDir(researchDir) {
83
83
  atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
84
84
  }
85
85
  catch {
86
- // Best-effort cache write; search results are already computed without it.
86
+ // Best-effort cache write; results are already computed without it.
87
87
  }
88
88
  }
89
89
  return results;
@@ -93,11 +93,11 @@ export function loadSearchableArtifactsForDir(researchDir) {
93
93
  * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
94
94
  * {@link scanCacheDirs} but reads through the per-root sidecar index.
95
95
  */
96
- export function loadSearchableArtifacts(dataDirs) {
96
+ export function loadIndexedArtifacts(dataDirs) {
97
97
  const seen = new Set();
98
98
  const all = [];
99
99
  for (const dataDir of dataDirs) {
100
- for (const located of loadSearchableArtifactsForDir(join(dataDir, 'research'))) {
100
+ for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'))) {
101
101
  const key = located.artifact.metadata.cache_key;
102
102
  if (seen.has(key))
103
103
  continue;
@@ -24,15 +24,8 @@ export declare class CdpClient {
24
24
  close(): void;
25
25
  }
26
26
  export declare function findChromePath(): string;
27
+ export declare function buildChromeArgs(): string[];
27
28
  export declare function waitForLoad(client: CdpClient, sessionId: string, timeoutMs: number, settleMs?: number): Promise<void>;
28
- /**
29
- * Polls until a content-container element holds enough RENDERED text, so SPAs that fetch their
30
- * body after load are captured rendered rather than as a "Loading…" shell. Pierces open shadow
31
- * roots (the container is matched across shadow boundaries) and measures `innerText`, which
32
- * reflects shadow-rendered text — `document.querySelector` + `textContent` miss both. Returns
33
- * without throwing on timeout; the caller captures whatever rendered and judges its quality.
34
- */
35
- export declare function waitForContentReady(page: CdpPage, selectors: string[], minChars: number, timeoutMs: number): Promise<void>;
36
29
  export declare const BLOCKED_ASSET_URLS: string[];
37
30
  export interface CdpPage {
38
31
  client: CdpClient;
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { checkDnsSafety } from './fetcher.js';
5
+ import { getChromeProxyArgs } from './proxy.js';
5
6
  import { normalizeUrl } from './url.js';
6
7
  export class CdpClient {
7
8
  ws;
@@ -93,8 +94,10 @@ export function findChromePath() {
93
94
  throw new Error('No Chrome or Chromium browser found for browser-based extraction. Install Chrome/Chromium or ' +
94
95
  'set CHROME_PATH to the browser executable. Static (non-rendered) fetching does not require a browser.');
95
96
  }
96
- async function spawnChrome(chromePath) {
97
- const chromeProcess = spawn(chromePath, [
97
+ // Split out from spawnChrome so the exact CLI args (including the proxy flags) are unit-testable
98
+ // without spawning a real browser process.
99
+ export function buildChromeArgs() {
100
+ return [
98
101
  '--headless=new',
99
102
  '--remote-debugging-port=0',
100
103
  '--disable-gpu',
@@ -102,7 +105,13 @@ async function spawnChrome(chromePath) {
102
105
  '--disable-setuid-sandbox',
103
106
  '--disable-extensions',
104
107
  '--disable-dev-shm-usage',
105
- ]);
108
+ // CDP navigation uses Chrome's own network stack, not Node's fetch/undici, so a sandbox
109
+ // proxy detected via *_PROXY env vars must be passed to Chrome separately (see proxy.ts).
110
+ ...getChromeProxyArgs(),
111
+ ];
112
+ }
113
+ async function spawnChrome(chromePath) {
114
+ const chromeProcess = spawn(chromePath, buildChromeArgs());
106
115
  try {
107
116
  const wsUrl = await new Promise((resolve, reject) => {
108
117
  let output = '';
@@ -164,48 +173,6 @@ export async function waitForLoad(client, sessionId, timeoutMs, settleMs = 1000)
164
173
  }
165
174
  throw new Error(`Navigation timed out after ${timeoutMs}ms`);
166
175
  }
167
- /**
168
- * Polls until a content-container element holds enough RENDERED text, so SPAs that fetch their
169
- * body after load are captured rendered rather than as a "Loading…" shell. Pierces open shadow
170
- * roots (the container is matched across shadow boundaries) and measures `innerText`, which
171
- * reflects shadow-rendered text — `document.querySelector` + `textContent` miss both. Returns
172
- * without throwing on timeout; the caller captures whatever rendered and judges its quality.
173
- */
174
- export async function waitForContentReady(page, selectors, minChars, timeoutMs) {
175
- // Reports whether a content container has rendered (innerText pierces shadow), plus the total
176
- // rendered text length so the caller can wait for the render to *settle* — heavy pages (e.g.
177
- // the api-console) render the container early but keep filling it in.
178
- const expression = `(() => {
179
- const out = [];
180
- const visit = (r) => { for (const c of Array.from(r.children || [])) { out.push(c); if (c.shadowRoot) visit(c.shadowRoot); visit(c); } };
181
- visit(document);
182
- const SELS = ${JSON.stringify(selectors)};
183
- let has = false;
184
- for (const el of out) {
185
- if (!el.matches) continue;
186
- if (!SELS.some((s) => { try { return el.matches(s); } catch (e) { return false; } })) continue;
187
- if (((el.innerText) || '').trim().length >= ${minChars}) { has = true; break; }
188
- }
189
- return { has, len: document.body ? (document.body.innerText || '').length : 0 };
190
- })()`;
191
- const start = Date.now();
192
- let previousLength = -1;
193
- let stablePolls = 0;
194
- while (Date.now() - start < timeoutMs) {
195
- const value = (await page.client
196
- .send('Runtime.evaluate', { expression, returnByValue: true }, page.sessionId)
197
- .catch(() => null))?.result?.value;
198
- if (value?.has) {
199
- const grew = Math.abs((value.len || 0) - previousLength) > Math.max(40, previousLength * 0.02);
200
- if (!grew && ++stablePolls >= 2)
201
- return; // content present and rendering has settled
202
- if (grew)
203
- stablePolls = 0;
204
- previousLength = value.len || 0;
205
- }
206
- await new Promise((r) => setTimeout(r, 500));
207
- }
208
- }
209
176
  export const BLOCKED_ASSET_URLS = [
210
177
  '*.css',
211
178
  '*.png',
@@ -336,7 +303,13 @@ export class ResponseCapture {
336
303
  });
337
304
  }
338
305
  }
339
- export async function fetchRenderedHtml(url, options = {}) {
306
+ // Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
307
+ const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
308
+ function isTransientBrowserNavigationError(err) {
309
+ const message = err instanceof Error ? err.message : String(err);
310
+ return TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code));
311
+ }
312
+ async function fetchRenderedHtmlOnce(url, options = {}) {
340
313
  const timeout = options.timeoutMs ?? 15_000;
341
314
  const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
342
315
  const currentUrl = normalizeUrl(url);
@@ -394,3 +367,16 @@ export async function fetchRenderedHtml(url, options = {}) {
394
367
  await page.close();
395
368
  }
396
369
  }
370
+ export async function fetchRenderedHtml(url, options = {}) {
371
+ const maxAttempts = 3;
372
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
373
+ try {
374
+ return await fetchRenderedHtmlOnce(url, options);
375
+ }
376
+ catch (err) {
377
+ if (attempt === maxAttempts || !isTransientBrowserNavigationError(err))
378
+ throw err;
379
+ }
380
+ }
381
+ throw new Error('Failed to fetch rendered HTML after transient browser navigation errors.');
382
+ }
@@ -1,4 +1,5 @@
1
1
  import { dropEmptyLinks, htmlToMarkdown } from '../markdown.js';
2
+ import { sanitizePromptInjection } from '../prompt-injection.js';
2
3
  // Parses a leading YAML frontmatter block (--- ... ---). Only flat scalar keys are read; nested
3
4
  // structures are ignored (we only need title/description). The block is stripped from the body.
4
5
  export function parseFrontmatter(md) {
@@ -183,7 +184,7 @@ export function extractFromSource(md, sourceUrl) {
183
184
  ? cleanKumaScript(stripMdnHiddenSamples(body))
184
185
  : body;
185
186
  const tablesAsMarkdown = convertHtmlTables(sanitizeSourceMarkdown(deMacroed));
186
- const cleaned = dropEmptySections(dropEmptyLinks(tablesAsMarkdown)).trim();
187
+ const cleaned = sanitizePromptInjection(dropEmptySections(dropEmptyLinks(tablesAsMarkdown))).trim();
187
188
  const notes = [`captured from public Markdown/MDX source: ${sourceUrl}`];
188
189
  return {
189
190
  title: titleFrom(frontmatter, cleaned),
@@ -4,6 +4,7 @@ import { htmlToMarkdown } from './markdown.js';
4
4
  import { normalizeCodeBlocks } from './docs/code-blocks.js';
5
5
  import { cleanDocsChrome } from './docs/clean-dom.js';
6
6
  import { analyzeMarkdownQuality } from './docs/quality-gates.js';
7
+ import { sanitizePromptInjection } from './prompt-injection.js';
7
8
  function resolveRelativeLinks(document, finalUrl) {
8
9
  const base = new URL(finalUrl);
9
10
  const links = document.querySelectorAll('a');
@@ -69,7 +70,7 @@ export function extractHtmlContent(html, finalUrl) {
69
70
  throw new Error('Content extraction failed: The target page could not be parsed by the readability engine. ' +
70
71
  'Hint: The "import" command lets you import pre-cleaned, agent-supplied research notes directly.');
71
72
  }
72
- const detailedMarkdown = htmlToMarkdown(article.content);
73
+ const detailedMarkdown = sanitizePromptInjection(htmlToMarkdown(article.content));
73
74
  const textLength = article.textContent ? article.textContent.trim().length : 0;
74
75
  const { confidence, notes } = determineConfidence(textLength);
75
76
  const quality = analyzeMarkdownQuality(detailedMarkdown, article.title || '');
@@ -1,6 +1,61 @@
1
1
  import { lookup } from 'node:dns/promises';
2
2
  import { isIP } from 'node:net';
3
+ import { fetch as undiciFetch } from 'undici';
3
4
  import { isSafeIp, normalizeUrl } from './url.js';
5
+ import { getProxyDispatcher, isProxyConfigured, PROXY_TUNNEL_REJECTION_PATTERN } from './proxy.js';
6
+ // Runs one fetch attempt with its own fresh AbortController/timeout, so that when `doFetch` makes
7
+ // a second attempt (the proxy fallback below) it gets the full timeout budget rather than
8
+ // whatever was left over from the first attempt's signal. Loosely typed at this one boundary
9
+ // because it bridges two structurally-similar-but-nominally-distinct fetch implementations
10
+ // (Node's global fetch and the separate `undici` package's own fetch/RequestInit/Response).
11
+ async function fetchWithTimeout(fetchFn, url, init, timeoutMs) {
12
+ const controller = new AbortController();
13
+ const id = setTimeout(() => controller.abort(), timeoutMs);
14
+ try {
15
+ return (await fetchFn(url, { ...init, signal: controller.signal }));
16
+ }
17
+ finally {
18
+ clearTimeout(id);
19
+ }
20
+ }
21
+ // True when `err` is a transport failure specifically caused by the proxy declining to tunnel to
22
+ // the target host (e.g. an allowlist-only sandbox proxy), as opposed to any other pre-response
23
+ // failure (DNS, refused connection, a genuinely unreachable destination). Only this class of
24
+ // failure is worth retrying without the proxy — retrying on any transport TypeError would also
25
+ // re-attempt destinations that are down regardless of the proxy, doubling their latency for no
26
+ // benefit.
27
+ function isProxyTunnelRejection(err) {
28
+ let current = err;
29
+ while (current instanceof Error) {
30
+ if (PROXY_TUNNEL_REJECTION_PATTERN.test(current.message))
31
+ return true;
32
+ current = current.cause;
33
+ }
34
+ return false;
35
+ }
36
+ // Node's global fetch cannot be handed a Dispatcher from the separate `undici` package (their
37
+ // internal request-handler interfaces don't line up across versions), so routing through a
38
+ // sandbox-detected proxy requires switching to undici's own fetch for that one request. The
39
+ // unproxied path keeps using the global fetch, matching existing tests that stub `globalThis.fetch`.
40
+ //
41
+ // A configured proxy isn't a guarantee the proxy can actually reach a given host (many sandbox
42
+ // proxies only allowlist specific destinations), so a proxy-tunnel rejection falls back to a
43
+ // direct connection rather than treating "proxy is configured" as "proxy or nothing." The
44
+ // fallback gets its own fresh timeout (via fetchWithTimeout) rather than inheriting the proxied
45
+ // attempt's spent budget.
46
+ async function doFetch(url, init, timeoutMs) {
47
+ const dispatcher = getProxyDispatcher();
48
+ if (!dispatcher)
49
+ return fetchWithTimeout(fetch, url, init, timeoutMs);
50
+ try {
51
+ return await fetchWithTimeout(undiciFetch, url, { ...init, dispatcher }, timeoutMs);
52
+ }
53
+ catch (err) {
54
+ if (!isProxyTunnelRejection(err))
55
+ throw err;
56
+ return fetchWithTimeout(fetch, url, init, timeoutMs);
57
+ }
58
+ }
4
59
  export function looksLikeHtml(text) {
5
60
  const trimmed = text.trimStart().toLowerCase();
6
61
  return (trimmed.startsWith('<!doctype html') ||
@@ -19,17 +74,26 @@ export async function checkDnsSafety(hostname) {
19
74
  }
20
75
  return;
21
76
  }
77
+ let addresses;
22
78
  try {
23
- const addresses = await lookup(hostToResolve, { all: true });
24
- for (const addr of addresses) {
25
- if (!isSafeIp(addr.address)) {
26
- throw new Error(`IP address "${addr.address}" resolved for "${hostname}" is a blocked local or private target.`);
27
- }
28
- }
79
+ addresses = await lookup(hostToResolve, { all: true });
29
80
  }
30
81
  catch (err) {
82
+ // A sandbox that requires a proxy for HTTP egress may also block raw DNS queries (port 53)
83
+ // without blocking name resolution done through the proxy itself — so a failed *local*
84
+ // lookup isn't necessarily a dead end when a proxy is available to fall back on. Only skip
85
+ // the safety guarantee in that specific case; whenever local resolution succeeds (the common
86
+ // case, proxy or not), the resolved address is still validated below regardless of proxy
87
+ // configuration, so a hostname that resolves to a private/internal IP is still caught.
88
+ if (isProxyConfigured())
89
+ return;
31
90
  throw new Error(`DNS resolution failed for hostname "${hostname}": ${err.message}`);
32
91
  }
92
+ for (const addr of addresses) {
93
+ if (!isSafeIp(addr.address)) {
94
+ throw new Error(`IP address "${addr.address}" resolved for "${hostname}" is a blocked local or private target.`);
95
+ }
96
+ }
33
97
  }
34
98
  async function readBodyWithLimit(body, limit) {
35
99
  if (!body) {
@@ -123,32 +187,20 @@ async function fetchWithRedirects(url, options, process) {
123
187
  while (true) {
124
188
  const parsedUrl = new URL(currentUrl);
125
189
  await checkDnsSafety(parsedUrl.hostname);
126
- const controller = new AbortController();
127
- const id = setTimeout(() => controller.abort(), timeout);
128
- try {
129
- const res = await fetch(currentUrl, {
130
- method: 'GET',
131
- headers: initialHeaders,
132
- redirect: 'manual',
133
- signal: controller.signal,
134
- });
135
- if ([301, 302, 303, 307, 308].includes(res.status)) {
136
- redirectCount++;
137
- if (redirectCount > maxRedirects) {
138
- throw new Error(`Too many redirects. Exceeded limit of ${maxRedirects}.`);
139
- }
140
- const location = res.headers.get('location');
141
- if (!location) {
142
- throw new Error(`Redirect response status ${res.status} missing Location header.`);
143
- }
144
- currentUrl = normalizeUrl(new URL(location, currentUrl).toString());
145
- continue;
190
+ const res = await doFetch(currentUrl, { method: 'GET', headers: initialHeaders, redirect: 'manual' }, timeout);
191
+ if ([301, 302, 303, 307, 308].includes(res.status)) {
192
+ redirectCount++;
193
+ if (redirectCount > maxRedirects) {
194
+ throw new Error(`Too many redirects. Exceeded limit of ${maxRedirects}.`);
146
195
  }
147
- return await process(res, limit, currentUrl);
148
- }
149
- finally {
150
- clearTimeout(id);
196
+ const location = res.headers.get('location');
197
+ if (!location) {
198
+ throw new Error(`Redirect response status ${res.status} missing Location header.`);
199
+ }
200
+ currentUrl = normalizeUrl(new URL(location, currentUrl).toString());
201
+ continue;
151
202
  }
203
+ return await process(res, limit, currentUrl);
152
204
  }
153
205
  }
154
206
  export async function fetchStaticHtml(url, options = {}) {
@@ -171,22 +223,14 @@ export async function postJson(url, body, headers = {}, options = {}) {
171
223
  const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
172
224
  const target = normalizeUrl(url);
173
225
  await checkDnsSafety(new URL(target).hostname);
174
- const controller = new AbortController();
175
- const id = setTimeout(() => controller.abort(), timeout);
176
- try {
177
- const res = await fetch(target, {
178
- method: 'POST',
179
- headers: { 'content-type': 'application/json', ...headers },
180
- body: JSON.stringify(body),
181
- redirect: 'error',
182
- signal: controller.signal,
183
- });
184
- if (!res.ok)
185
- throw new Error(`Search request failed with status ${res.status} ${res.statusText}`);
186
- const bytes = await readBodyWithLimit(res.body, limit);
187
- return new TextDecoder().decode(bytes);
188
- }
189
- finally {
190
- clearTimeout(id);
191
- }
226
+ const res = await doFetch(target, {
227
+ method: 'POST',
228
+ headers: { 'content-type': 'application/json', ...headers },
229
+ body: JSON.stringify(body),
230
+ redirect: 'error',
231
+ }, timeout);
232
+ if (!res.ok)
233
+ throw new Error(`Search request failed with status ${res.status} ${res.statusText}`);
234
+ const bytes = await readBodyWithLimit(res.body, limit);
235
+ return new TextDecoder().decode(bytes);
192
236
  }
@@ -0,0 +1 @@
1
+ export declare function sanitizePromptInjection(markdown: string): string;