@taurgis/bonsai 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +9 -6
  2. package/agents/agents/salesforce-docs-researcher.agent.md +17 -4
  3. package/agents/instructions/salesforce-research.instructions.md +2 -1
  4. package/bin/cli.mjs +13 -18
  5. package/dist/base-command.d.ts +59 -12
  6. package/dist/base-command.js +179 -51
  7. package/dist/commands/config/base.js +12 -2
  8. package/dist/commands/config/get.js +8 -12
  9. package/dist/commands/config/index.js +2 -2
  10. package/dist/commands/config/list.js +11 -17
  11. package/dist/commands/config/set.d.ts +2 -1
  12. package/dist/commands/config/set.js +35 -28
  13. package/dist/commands/config/unset.d.ts +2 -1
  14. package/dist/commands/config/unset.js +26 -25
  15. package/dist/commands/fetch.d.ts +7 -15
  16. package/dist/commands/fetch.js +61 -401
  17. package/dist/commands/import.d.ts +4 -9
  18. package/dist/commands/import.js +56 -284
  19. package/dist/commands/inspect.d.ts +7 -1
  20. package/dist/commands/inspect.js +112 -52
  21. package/dist/commands/list.d.ts +18 -3
  22. package/dist/commands/list.js +121 -46
  23. package/dist/commands/prune.d.ts +6 -1
  24. package/dist/commands/prune.js +89 -78
  25. package/dist/commands/status.d.ts +3 -2
  26. package/dist/commands/status.js +62 -79
  27. package/dist/hooks/command-not-found/suggest.d.ts +2 -1
  28. package/dist/hooks/command-not-found/suggest.js +78 -41
  29. package/dist/lib/argv.d.ts +29 -3
  30. package/dist/lib/argv.js +147 -54
  31. package/dist/lib/batch.d.ts +41 -0
  32. package/dist/lib/batch.js +39 -0
  33. package/dist/lib/cache-view.d.ts +21 -0
  34. package/dist/lib/cache-view.js +30 -0
  35. package/dist/lib/cli-emit.d.ts +28 -0
  36. package/dist/lib/cli-emit.js +44 -0
  37. package/dist/lib/cli-error-policy.d.ts +57 -0
  38. package/dist/lib/cli-error-policy.js +74 -0
  39. package/dist/lib/cli-flag-manifest.d.ts +12 -0
  40. package/dist/lib/cli-flag-manifest.js +15 -0
  41. package/dist/lib/cli-presentation.d.ts +41 -0
  42. package/dist/lib/cli-presentation.js +53 -0
  43. package/dist/lib/cli-result-types.d.ts +112 -0
  44. package/dist/lib/cli-result-types.js +2 -0
  45. package/dist/lib/config/index.d.ts +23 -5
  46. package/dist/lib/config/index.js +60 -5
  47. package/dist/lib/config/io.d.ts +9 -0
  48. package/dist/lib/config/io.js +71 -21
  49. package/dist/lib/config/schema.d.ts +2 -0
  50. package/dist/lib/config/schema.js +7 -0
  51. package/dist/lib/config-persist.d.ts +28 -0
  52. package/dist/lib/config-persist.js +29 -0
  53. package/dist/lib/envelope.d.ts +41 -3
  54. package/dist/lib/envelope.js +154 -4
  55. package/dist/lib/help-preflight.d.ts +5 -8
  56. package/dist/lib/help-preflight.js +13 -32
  57. package/dist/lib/json-meta.d.ts +0 -2
  58. package/dist/lib/json-meta.js +21 -25
  59. package/dist/lib/limit-flag.d.ts +11 -1
  60. package/dist/lib/limit-flag.js +19 -6
  61. package/dist/lib/parse-error-ux.d.ts +5 -0
  62. package/dist/lib/parse-error-ux.js +73 -0
  63. package/dist/lib/prune-flags.d.ts +30 -0
  64. package/dist/lib/prune-flags.js +86 -0
  65. package/dist/lib/research/artifact-index.d.ts +11 -2
  66. package/dist/lib/research/artifact-index.js +14 -5
  67. package/dist/lib/research/artifact.js +20 -2
  68. package/dist/lib/research/browser.d.ts +53 -2
  69. package/dist/lib/research/browser.js +202 -58
  70. package/dist/lib/research/capture.d.ts +24 -2
  71. package/dist/lib/research/capture.js +24 -4
  72. package/dist/lib/research/cli-io.d.ts +40 -0
  73. package/dist/lib/research/cli-io.js +27 -0
  74. package/dist/lib/research/compress.d.ts +7 -0
  75. package/dist/lib/research/compress.js +7 -3
  76. package/dist/lib/research/docs/capabilities.d.ts +15 -0
  77. package/dist/lib/research/docs/capabilities.js +5 -0
  78. package/dist/lib/research/docs/clean-dom.d.ts +2 -0
  79. package/dist/lib/research/docs/clean-dom.js +2 -0
  80. package/dist/lib/research/docs/code-blocks.d.ts +2 -0
  81. package/dist/lib/research/docs/code-blocks.js +2 -0
  82. package/dist/lib/research/docs/detect.d.ts +4 -0
  83. package/dist/lib/research/docs/detect.js +4 -0
  84. package/dist/lib/research/docs/machine-readable.d.ts +18 -0
  85. package/dist/lib/research/docs/machine-readable.js +10 -0
  86. package/dist/lib/research/docs/markdown-source.d.ts +20 -0
  87. package/dist/lib/research/docs/markdown-source.js +19 -2
  88. package/dist/lib/research/docs/quality-gates.d.ts +5 -0
  89. package/dist/lib/research/docs/quality-gates.js +4 -0
  90. package/dist/lib/research/docs/rsc.d.ts +9 -2
  91. package/dist/lib/research/docs/rsc.js +5 -2
  92. package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
  93. package/dist/lib/research/docs/section-artifacts.js +12 -0
  94. package/dist/lib/research/docs/sections.d.ts +18 -0
  95. package/dist/lib/research/docs/sections.js +17 -1
  96. package/dist/lib/research/docs/source-map.d.ts +28 -0
  97. package/dist/lib/research/docs/source-map.js +27 -0
  98. package/dist/lib/research/docs/validate.d.ts +35 -2
  99. package/dist/lib/research/docs/validate.js +47 -2
  100. package/dist/lib/research/extract.d.ts +16 -0
  101. package/dist/lib/research/extract.js +12 -1
  102. package/dist/lib/research/fetch-command-service.d.ts +36 -0
  103. package/dist/lib/research/fetch-command-service.js +367 -0
  104. package/dist/lib/research/fetch-result.d.ts +114 -0
  105. package/dist/lib/research/fetch-result.js +212 -0
  106. package/dist/lib/research/fetcher.d.ts +46 -8
  107. package/dist/lib/research/fetcher.js +61 -31
  108. package/dist/lib/research/freshness.d.ts +54 -11
  109. package/dist/lib/research/freshness.js +91 -31
  110. package/dist/lib/research/import-command-service.d.ts +56 -0
  111. package/dist/lib/research/import-command-service.js +402 -0
  112. package/dist/lib/research/keywords.js +5 -1
  113. package/dist/lib/research/markdown.d.ts +12 -1
  114. package/dist/lib/research/markdown.js +13 -5
  115. package/dist/lib/research/metadata-filters.d.ts +18 -0
  116. package/dist/lib/research/metadata-filters.js +37 -0
  117. package/dist/lib/research/metadata-flags.d.ts +34 -0
  118. package/dist/lib/research/metadata-flags.js +45 -0
  119. package/dist/lib/research/persist-artifact.d.ts +23 -0
  120. package/dist/lib/research/persist-artifact.js +27 -0
  121. package/dist/lib/research/prompt-injection.d.ts +9 -0
  122. package/dist/lib/research/prompt-injection.js +59 -14
  123. package/dist/lib/research/proxy.d.ts +14 -8
  124. package/dist/lib/research/proxy.js +14 -10
  125. package/dist/lib/research/resolve-target.d.ts +2 -0
  126. package/dist/lib/research/resolve-target.js +1 -1
  127. package/dist/lib/research/revalidate.d.ts +49 -20
  128. package/dist/lib/research/revalidate.js +80 -15
  129. package/dist/lib/research/schema.d.ts +7 -0
  130. package/dist/lib/research/schema.js +2 -0
  131. package/dist/lib/research/secret-scan.d.ts +6 -0
  132. package/dist/lib/research/secret-scan.js +8 -0
  133. package/dist/lib/research/secure-write.d.ts +4 -0
  134. package/dist/lib/research/secure-write.js +31 -2
  135. package/dist/lib/research/storage.d.ts +9 -8
  136. package/dist/lib/research/storage.js +34 -28
  137. package/dist/lib/research/summarize.d.ts +15 -0
  138. package/dist/lib/research/summarize.js +11 -0
  139. package/dist/lib/research/url.d.ts +9 -6
  140. package/dist/lib/research/url.js +33 -21
  141. package/dist/lib/text.d.ts +19 -8
  142. package/dist/lib/text.js +32 -9
  143. package/dist/lib/value-flags.d.ts +14 -0
  144. package/dist/lib/value-flags.js +28 -0
  145. package/dist/sites/coveo.d.ts +10 -0
  146. package/dist/sites/coveo.js +10 -0
  147. package/dist/sites/index.d.ts +16 -0
  148. package/dist/sites/index.js +16 -4
  149. package/dist/sites/markdown-twin.d.ts +31 -0
  150. package/dist/sites/markdown-twin.js +56 -0
  151. package/dist/sites/salesforce/fetch-page.d.ts +8 -2
  152. package/dist/sites/salesforce/fetch-page.js +14 -3
  153. package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
  154. package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
  155. package/dist/sites/salesforce/index.d.ts +1 -0
  156. package/dist/sites/salesforce/index.js +1 -0
  157. package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
  158. package/dist/sites/salesforce-developer/fetch-page.js +6 -1
  159. package/dist/sites/salesforce-developer/index.d.ts +1 -0
  160. package/dist/sites/salesforce-developer/index.js +1 -0
  161. package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
  162. package/dist/sites/salesforce-developer/route-markdown.js +29 -67
  163. package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
  164. package/dist/sites/salesforce-doc-fetch.js +52 -7
  165. package/dist/sites/salesforce-dom-probe.d.ts +20 -2
  166. package/dist/sites/salesforce-dom-probe.js +20 -2
  167. package/dist/sites/types.d.ts +14 -8
  168. package/dist/sites/types.js +3 -0
  169. package/oclif.manifest.json +216 -102
  170. package/package.json +20 -7
  171. package/agents/agents/official-docs-researcher.agent.md +0 -87
  172. package/agents/instructions/web-research.instructions.md +0 -44
  173. package/agents/skills/web-research/SKILL.md +0 -118
@@ -39,7 +39,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
39
39
  return cached;
40
40
  }
41
41
  try {
42
- const entry = { sig, artifact: parseArtifactShallow(readFileSync(filePath, 'utf-8')) };
42
+ const artifact = parseArtifactShallow(readFileSync(filePath, 'utf-8'));
43
+ // Mirrors the same guard in storage.ts's scanCacheDir: parseArtifact never throws on a foreign
44
+ // or garbled *.md file that merely has `---`/`---` fences, it just defaults cache_key to ''. Treat
45
+ // that the same as a parse failure so it's skipped rather than indexed as a phantom entry.
46
+ if (!artifact.metadata.cache_key)
47
+ return undefined;
48
+ const entry = { sig, artifact };
43
49
  onChanged();
44
50
  return entry;
45
51
  }
@@ -52,10 +58,13 @@ function getOrUpdateIndexEntry(filePath, cached, onChanged) {
52
58
  * to skip unchanged files. Re-reads only files whose signature changed, then persists the refreshed
53
59
  * index (best-effort; a write failure never breaks callers). Corrupt/unreadable files are skipped —
54
60
  * the real read paths in storage.ts handle archiving them.
61
+ *
62
+ * Pass `{ persist: false }` under read-only/plan mode so the derived sidecar is never written.
55
63
  */
56
- export function loadIndexedArtifactsForDir(researchDir) {
64
+ export function loadIndexedArtifactsForDir(researchDir, options = {}) {
57
65
  if (!existsSync(researchDir))
58
66
  return [];
67
+ const persist = options.persist !== false;
59
68
  const indexPath = join(researchDir, INDEX_FILE);
60
69
  const previous = loadIndex(indexPath);
61
70
  const entries = {};
@@ -78,7 +87,7 @@ export function loadIndexedArtifactsForDir(researchDir) {
78
87
  if (!changed) {
79
88
  changed = Object.keys(previous.entries).some((key) => !(key in entries));
80
89
  }
81
- if (changed) {
90
+ if (changed && persist) {
82
91
  try {
83
92
  atomicWriteFile(indexPath, JSON.stringify({ version: INDEX_VERSION, entries }));
84
93
  }
@@ -93,11 +102,11 @@ export function loadIndexedArtifactsForDir(researchDir) {
93
102
  * key so a project entry shadows the same key in the global cache. Mirrors the dedup semantics of
94
103
  * {@link scanCacheDirs} but reads through the per-root sidecar index.
95
104
  */
96
- export function loadIndexedArtifacts(dataDirs) {
105
+ export function loadIndexedArtifacts(dataDirs, options = {}) {
97
106
  const seen = new Set();
98
107
  const all = [];
99
108
  for (const dataDir of dataDirs) {
100
- for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'))) {
109
+ for (const located of loadIndexedArtifactsForDir(join(dataDir, 'research'), options)) {
101
110
  const key = located.artifact.metadata.cache_key;
102
111
  if (seen.has(key))
103
112
  continue;
@@ -1,12 +1,30 @@
1
+ import { CONTROL_CHAR_PATTERN } from '../text.js';
2
+ // Frontmatter is one field per line. An embedded newline (or tab, treated the same way as
3
+ // legitimate Markdown whitespace elsewhere in this codebase — see sanitizeForTerminal in
4
+ // ../text.ts and stripUnsafeControlChars in ./prompt-injection.ts) in a free-form value (e.g. an
5
+ // agent-supplied --topic or --tags) would inject a bogus extra line — at best a dropped stray
6
+ // line, at worst a fake `key: value` pair, or a bare `---` that closes the frontmatter fence early
7
+ // and splices the remaining metadata fields (and even the closing fence and body sections) into
8
+ // what parseArtifact then treats as free-text content. Collapsing that whitespace to a space
9
+ // (rather than deleting it, which would silently merge adjacent words) keeps every value confined
10
+ // to the single line the format requires, regardless of where the string originated. Other control
11
+ // characters (e.g. a raw ANSI escape byte, whether from an agent-supplied flag or extracted page
12
+ // text) are stripped outright: the cache file is plain text meant to be `cat`ed or displayed by
13
+ // `inspect`/`list`, and an uncaught escape sequence there would reach a real terminal — unlike
14
+ // --json output, which JSON-escapes control characters by construction.
15
+ function sanitizeFrontmatterLine(value) {
16
+ return value.replace(/[\t\r\n]+/g, ' ').replace(CONTROL_CHAR_PATTERN, '');
17
+ }
1
18
  function serializeArrayField(lines, name, items) {
2
19
  lines.push(`${name}:`);
3
20
  for (const item of items) {
4
- lines.push(` - ${item}`);
21
+ lines.push(` - ${sanitizeFrontmatterLine(item)}`);
5
22
  }
6
23
  }
7
24
  // A scalar frontmatter line, blanking null/undefined. Using `??` (not `||`) preserves a numeric 0.
8
25
  function serializeScalarField(lines, name, value) {
9
- lines.push(`${name}: ${value ?? ''}`);
26
+ const safeValue = typeof value === 'string' ? sanitizeFrontmatterLine(value) : value;
27
+ lines.push(`${name}: ${safeValue ?? ''}`);
10
28
  }
11
29
  /**
12
30
  * Serializes artifact metadata to standard YAML frontmatter text.
@@ -1,3 +1,4 @@
1
+ import { ChildProcess } from 'node:child_process';
1
2
  export interface BrowserFetchOptions {
2
3
  timeoutMs?: number;
3
4
  bodyLimitBytes?: number;
@@ -25,12 +26,27 @@ export declare class CdpClient {
25
26
  }
26
27
  export declare function findChromePath(): string;
27
28
  export declare function buildChromeArgs(): string[];
29
+ /**
30
+ * Kill Chrome and every child process it spawned (zygote, gpu-process, renderer, crashpad).
31
+ * `chromeProcess.kill()` alone only signals that one PID — per Node's child_process docs, a signal
32
+ * sent to a child is not propagated to *that child's own* children on Linux, so Chrome's
33
+ * multi-process tree survives a plain kill on every rendered fetch, not just an interrupted one.
34
+ * Spawning with `detached: true` makes Chrome the leader of its own process group, so signaling the
35
+ * negative PID reaches the whole tree in one call. Falls back to a direct kill if group-kill fails
36
+ * (already exited, or a platform without POSIX process groups).
37
+ */
38
+ export declare function killChromeTree(chromeProcess: ChildProcess): void;
28
39
  export declare function waitForLoad(client: CdpClient, sessionId: string, timeoutMs: number, settleMs?: number): Promise<void>;
29
40
  export declare const BLOCKED_ASSET_URLS: string[];
30
41
  export interface CdpPage {
31
42
  client: CdpClient;
32
43
  sessionId: string;
33
44
  close: () => Promise<void>;
45
+ /**
46
+ * Returns (and clears) the reason the most recent navigation request was blocked by the
47
+ * per-hop safety guard installed in {@link openCdpPage}, or `null` if none was blocked.
48
+ */
49
+ takeBlockedNavigationError: () => Error | null;
34
50
  }
35
51
  /** Status line of the main-frame document response, captured from Network.responseReceived. */
36
52
  export interface MainDocumentResponse {
@@ -45,8 +61,20 @@ export interface MainDocumentResponse {
45
61
  */
46
62
  export declare function assertRenderedHttpOk(mainDoc: MainDocumentResponse | undefined): void;
47
63
  /**
48
- * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
49
- * and Runtime domains. The caller drives navigation and must call close() when finished.
64
+ * Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
65
+ * safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
66
+ * follows redirects internally with no hook back into that check, so a page that starts on a safe
67
+ * public host could otherwise redirect a rendered-fallback capture into a private/internal address
68
+ * (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
69
+ */
70
+ export declare function describeUnsafeNavigationTarget(url: string): Promise<Error | null>;
71
+ /**
72
+ * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
73
+ * and Fetch domains. Fetch domain interception guards every Document-type request (the initial
74
+ * navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
75
+ * Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
76
+ * the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
77
+ * close() when finished.
50
78
  */
51
79
  export declare function openCdpPage(): Promise<CdpPage>;
52
80
  export interface ResponseMatcher {
@@ -89,4 +117,27 @@ export declare const SANDBOX_EGRESS_ERROR_MARKER = "sandboxed execution environm
89
117
  * against the environment directly and fall back to a manual fetch if it's still unreachable.
90
118
  */
91
119
  export declare function describeNavigationFailure(errorText: string, url: string): Error;
120
+ export declare function isTransientBrowserError(err: unknown): boolean;
121
+ /**
122
+ * Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
123
+ * openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
124
+ * discarded in favor of a less specific (or entirely silent) failure:
125
+ *
126
+ * 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
127
+ * resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
128
+ * below if not checked first.
129
+ * 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
130
+ * the initial navigation already settled can prevent the load/domContentLoaded events
131
+ * `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
132
+ * 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
133
+ * the load signal would otherwise sail through silently, capturing whatever the page settled on.
134
+ *
135
+ * Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
136
+ * `CdpPage` without spawning a real browser.
137
+ */
138
+ export declare function captureNavigatedPage(page: CdpPage, currentUrl: string, options: {
139
+ timeout: number;
140
+ limit: number;
141
+ settleMs: number;
142
+ }): Promise<BrowserFetchResult>;
92
143
  export declare function fetchRenderedHtml(url: string, options?: BrowserFetchOptions): Promise<BrowserFetchResult>;
@@ -130,13 +130,63 @@ export function buildChromeArgs() {
130
130
  // CI runners occasionally starve headless Chrome's startup (disk/CPU contention from concurrent
131
131
  // jobs), so this is generous enough to absorb that rather than a "typical" local startup time.
132
132
  const CHROME_STARTUP_TIMEOUT_MS = 20_000;
133
+ // Node's default SIGINT/SIGTERM disposition kills the process immediately, without waiting for the
134
+ // async `finally { page.close() }` in fetchRenderedHtmlOnce to run — so an interrupted --rendered
135
+ // fetch otherwise abandons a whole Chrome process tree (main + zygote/gpu/renderer children). The
136
+ // plain 'exit' event covers ordinary completion (belt-and-braces alongside close()'s own cleanup);
137
+ // a `once` signal listener runs the same cleanup, then re-sends the signal to this process. Because
138
+ // `once` already removed itself, that re-delivery has no listener left and Node's own default
139
+ // disposition takes over — the process still exits with the normal 128+signum code, with no exit-code
140
+ // bookkeeping of our own.
141
+ const liveChromeProcesses = new Set();
142
+ let chromeCleanupRegistered = false;
143
+ function killLiveChromeProcesses() {
144
+ for (const chromeProcess of liveChromeProcesses)
145
+ killChromeTree(chromeProcess);
146
+ }
147
+ function ensureChromeCleanupRegistered() {
148
+ if (chromeCleanupRegistered)
149
+ return;
150
+ chromeCleanupRegistered = true;
151
+ process.once('exit', killLiveChromeProcesses);
152
+ for (const signal of ['SIGINT', 'SIGTERM']) {
153
+ process.once(signal, () => {
154
+ killLiveChromeProcesses();
155
+ process.kill(process.pid, signal);
156
+ });
157
+ }
158
+ }
159
+ /**
160
+ * Kill Chrome and every child process it spawned (zygote, gpu-process, renderer, crashpad).
161
+ * `chromeProcess.kill()` alone only signals that one PID — per Node's child_process docs, a signal
162
+ * sent to a child is not propagated to *that child's own* children on Linux, so Chrome's
163
+ * multi-process tree survives a plain kill on every rendered fetch, not just an interrupted one.
164
+ * Spawning with `detached: true` makes Chrome the leader of its own process group, so signaling the
165
+ * negative PID reaches the whole tree in one call. Falls back to a direct kill if group-kill fails
166
+ * (already exited, or a platform without POSIX process groups).
167
+ */
168
+ export function killChromeTree(chromeProcess) {
169
+ if (typeof chromeProcess.pid === 'number') {
170
+ try {
171
+ process.kill(-chromeProcess.pid, 'SIGKILL');
172
+ return;
173
+ }
174
+ catch {
175
+ // Fall through to a best-effort direct kill below.
176
+ }
177
+ }
178
+ chromeProcess.kill('SIGKILL');
179
+ }
133
180
  async function spawnChrome(chromePath) {
134
- const chromeProcess = spawn(chromePath, buildChromeArgs());
181
+ ensureChromeCleanupRegistered();
182
+ const chromeProcess = spawn(chromePath, buildChromeArgs(), { detached: true });
183
+ liveChromeProcesses.add(chromeProcess);
184
+ chromeProcess.once('exit', () => liveChromeProcesses.delete(chromeProcess));
135
185
  try {
136
186
  const wsUrl = await new Promise((resolve, reject) => {
137
187
  let output = '';
138
188
  const timeoutId = setTimeout(() => {
139
- chromeProcess.kill('SIGKILL');
189
+ killChromeTree(chromeProcess);
140
190
  reject(new Error('Timed out waiting for Chrome to start.'));
141
191
  }, CHROME_STARTUP_TIMEOUT_MS);
142
192
  const onData = (data) => {
@@ -168,7 +218,7 @@ async function spawnChrome(chromePath) {
168
218
  return { chromeProcess, wsUrl };
169
219
  }
170
220
  catch (err) {
171
- chromeProcess.kill('SIGKILL');
221
+ killChromeTree(chromeProcess);
172
222
  throw err;
173
223
  }
174
224
  }
@@ -220,13 +270,41 @@ export function assertRenderedHttpOk(mainDoc) {
220
270
  }
221
271
  }
222
272
  /**
223
- * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network,
224
- * and Runtime domains. The caller drives navigation and must call close() when finished.
273
+ * Validates a navigated (or redirected) Document request against the same scheme/credentials/DNS
274
+ * safety `normalizeUrl` and `fetchWithRedirects` enforce per hop in url.ts/fetcher.ts. Chrome
275
+ * follows redirects internally with no hook back into that check, so a page that starts on a safe
276
+ * public host could otherwise redirect a rendered-fallback capture into a private/internal address
277
+ * (SSRF) or a non-http(s) scheme (e.g. `file:`) that the static fetcher would have rejected outright.
278
+ */
279
+ export async function describeUnsafeNavigationTarget(url) {
280
+ let normalized;
281
+ try {
282
+ normalized = normalizeUrl(url);
283
+ }
284
+ catch (err) {
285
+ return err;
286
+ }
287
+ try {
288
+ await checkDnsSafety(new URL(normalized).hostname);
289
+ return null;
290
+ }
291
+ catch (err) {
292
+ return err;
293
+ }
294
+ }
295
+ /**
296
+ * Spawns a headless Chrome, attaches a flat CDP session, and enables the Page, Network, Runtime,
297
+ * and Fetch domains. Fetch domain interception guards every Document-type request (the initial
298
+ * navigation and each subsequent redirect) with {@link describeUnsafeNavigationTarget} before
299
+ * Chrome is allowed to send it; a blocked request's reason is recorded and can be read back via
300
+ * the returned page's `takeBlockedNavigationError()`. The caller drives navigation and must call
301
+ * close() when finished.
225
302
  */
226
303
  export async function openCdpPage() {
227
304
  const chromePath = findChromePath();
228
305
  const { chromeProcess, wsUrl } = await spawnChrome(chromePath);
229
306
  const client = new CdpClient(wsUrl);
307
+ let blockedNavigationError = null;
230
308
  try {
231
309
  await client.connect();
232
310
  const { targetId } = await client.send('Target.createTarget', { url: 'about:blank' });
@@ -235,19 +313,41 @@ export async function openCdpPage() {
235
313
  await client.send('Network.enable', {}, sessionId);
236
314
  await client.send('Runtime.enable', {}, sessionId);
237
315
  await client.send('Network.setUserAgentOverride', { userAgent: DEFAULT_USER_AGENT }, sessionId);
316
+ await client.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
317
+ client.on(`${sessionId}:Fetch.requestPaused`, (params) => {
318
+ // CdpClient.on's handler type is synchronous (CDP event dispatch isn't awaited), so the
319
+ // actual async validate-then-continue/fail work runs in this detached IIFE instead.
320
+ void (async () => {
321
+ const { requestId, request } = params;
322
+ const blockReason = await describeUnsafeNavigationTarget(request.url);
323
+ if (blockReason) {
324
+ blockedNavigationError = blockReason;
325
+ await client
326
+ .send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId)
327
+ .catch(() => { });
328
+ return;
329
+ }
330
+ await client.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
331
+ })();
332
+ });
238
333
  const close = async () => {
239
334
  try {
240
335
  await client.send('Target.closeTarget', { targetId });
241
336
  }
242
337
  catch { }
243
338
  client.close();
244
- chromeProcess.kill('SIGKILL');
339
+ killChromeTree(chromeProcess);
245
340
  };
246
- return { client, sessionId, close };
341
+ const takeBlockedNavigationError = () => {
342
+ const err = blockedNavigationError;
343
+ blockedNavigationError = null;
344
+ return err;
345
+ };
346
+ return { client, sessionId, close, takeBlockedNavigationError };
247
347
  }
248
348
  catch (err) {
249
349
  client.close();
250
- chromeProcess.kill('SIGKILL');
350
+ killChromeTree(chromeProcess);
251
351
  throw err;
252
352
  }
253
353
  }
@@ -332,8 +432,8 @@ const PROXY_BLOCKED_CHROME_NET_ERRORS = [
332
432
  'ERR_PROXY_CONNECTION_FAILED',
333
433
  'ERR_SOCKS_CONNECTION_FAILED',
334
434
  ];
335
- // Shared substring fetch.ts's fetchFailureGuidance matches on to attach suggestions/ref to this
336
- // error, without duplicating the message text it needs to match against.
435
+ // Shared substring fetch-result.ts's fetchFailureGuidance matches on to attach suggestions/ref to
436
+ // this error, without duplicating the message text it needs to match against.
337
437
  export const SANDBOX_EGRESS_ERROR_MARKER = 'sandboxed execution environment';
338
438
  /**
339
439
  * Builds the error thrown for a failed Page.navigate. When a proxy is configured (proxy.ts's
@@ -364,8 +464,11 @@ export function describeNavigationFailure(errorText, url) {
364
464
  `or bypassing the proxy: confirm "${host}" is reachable from this environment (e.g. test the ` +
365
465
  `proxy directly), or fetch the page from a network with access and import it instead.`);
366
466
  }
367
- // Chrome can emit these on first navigation in headless CI when the cert verifier reloads mid-run.
368
- const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED'];
467
+ // ERR_CERT_VERIFIER_CHANGED: Chrome can emit this on first navigation in headless CI when the cert
468
+ // verifier reloads mid-run. ERR_CONNECTION_CLOSED is Chromium's code for a plain remote TCP FIN
469
+ // (net_error_list.h), which CI runners can hit as a one-off network blip against a real host
470
+ // rather than a real block.
471
+ const TRANSIENT_NAV_ERROR_CODES = ['ERR_CERT_VERIFIER_CHANGED', 'ERR_CONNECTION_CLOSED'];
369
472
  // Resource contention in CI (concurrent jobs competing for CPU/disk) can make Chrome itself fail
370
473
  // to come up in time, or die before its devtools websocket URL is printed. Neither reflects a
371
474
  // problem with the target page, so it's worth a fresh attempt rather than failing the whole fetch.
@@ -373,11 +476,93 @@ const TRANSIENT_CHROME_STARTUP_MESSAGES = [
373
476
  'Timed out waiting for Chrome to start.',
374
477
  'Chrome exited prematurely',
375
478
  ];
376
- function isTransientBrowserError(err) {
479
+ // Exported so tests can assert the transient-error classification directly rather than only
480
+ // indirectly through a live fetchRenderedHtml retry, which needs a real Chrome navigation to fail.
481
+ export function isTransientBrowserError(err) {
377
482
  const message = err instanceof Error ? err.message : String(err);
378
483
  return (TRANSIENT_NAV_ERROR_CODES.some((code) => message.includes(code)) ||
379
484
  TRANSIENT_CHROME_STARTUP_MESSAGES.some((text) => message.includes(text)));
380
485
  }
486
+ /**
487
+ * Navigates an already-open CDP page to `currentUrl` and captures the rendered HTML, honoring the
488
+ * openCdpPage Fetch-domain safety guard at every point a blocked navigation could otherwise be
489
+ * discarded in favor of a less specific (or entirely silent) failure:
490
+ *
491
+ * 1. Right after `Page.navigate` returns — a fast HTTP redirect chain can already be blocked and
492
+ * resolved by this point, surfacing as the generic `nav.errorText` (e.g. `net::ERR_BLOCKED_BY_CLIENT`)
493
+ * below if not checked first.
494
+ * 2. If `waitForLoad` throws — a client-side (JS-triggered) navigation into a blocked target after
495
+ * the initial navigation already settled can prevent the load/domContentLoaded events
496
+ * `waitForLoad` is waiting on, surfacing as a generic timeout instead of the specific reason.
497
+ * 3. After `waitForLoad` resolves — a client-side navigation blocked without otherwise disrupting
498
+ * the load signal would otherwise sail through silently, capturing whatever the page settled on.
499
+ *
500
+ * Exported (rather than folded into `fetchRenderedHtmlOnce`) so tests can drive it against a fake
501
+ * `CdpPage` without spawning a real browser.
502
+ */
503
+ export async function captureNavigatedPage(page, currentUrl, options) {
504
+ const { timeout, limit, settleMs } = options;
505
+ await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
506
+ // Record the status of each frame's first Document response so we can reject HTTP errors after
507
+ // the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
508
+ const documentResponses = new Map();
509
+ page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
510
+ const { frameId } = params;
511
+ const status = params.response?.status;
512
+ // Only record real numeric statuses: a Document event without a usable status would
513
+ // otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
514
+ // fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
515
+ if (params.type !== 'Document' || !frameId || typeof status !== 'number')
516
+ return;
517
+ if (documentResponses.has(frameId))
518
+ return;
519
+ documentResponses.set(frameId, {
520
+ status,
521
+ statusText: params.response?.statusText ?? '',
522
+ });
523
+ });
524
+ const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
525
+ // See point 1 above: prefer the safety guard's specific reason over the opaque errorText below.
526
+ const blockedNavigation = page.takeBlockedNavigationError();
527
+ if (blockedNavigation)
528
+ throw blockedNavigation;
529
+ // A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
530
+ // fail fast here instead of waiting out the full navigation timeout on a dead page.
531
+ if (nav.errorText) {
532
+ throw describeNavigationFailure(nav.errorText, currentUrl);
533
+ }
534
+ try {
535
+ await waitForLoad(page.client, page.sessionId, timeout, settleMs);
536
+ }
537
+ catch (err) {
538
+ // See point 2 above: a blocked reason recorded during the wait outranks the generic timeout.
539
+ throw page.takeBlockedNavigationError() ?? err;
540
+ }
541
+ // See point 3 above.
542
+ const blockedNavigationAfterLoad = page.takeBlockedNavigationError();
543
+ if (blockedNavigationAfterLoad)
544
+ throw blockedNavigationAfterLoad;
545
+ const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
546
+ assertRenderedHttpOk(mainDoc);
547
+ const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
548
+ const html = evalResult.result?.value;
549
+ if (typeof html !== 'string') {
550
+ throw new Error('Failed to retrieve rendered HTML from browser page.');
551
+ }
552
+ const byteLength = Buffer.byteLength(html);
553
+ if (byteLength > limit) {
554
+ throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
555
+ }
556
+ return {
557
+ status: mainDoc?.status ?? 200,
558
+ contentType: 'text/html',
559
+ etag: null,
560
+ lastModified: null,
561
+ finalUrl: currentUrl,
562
+ responseSize: byteLength,
563
+ content: html,
564
+ };
565
+ }
381
566
  async function fetchRenderedHtmlOnce(url, options = {}) {
382
567
  const timeout = options.timeoutMs ?? 15_000;
383
568
  const limit = options.bodyLimitBytes ?? 2 * 1024 * 1024;
@@ -385,52 +570,11 @@ async function fetchRenderedHtmlOnce(url, options = {}) {
385
570
  await checkDnsSafety(new URL(currentUrl).hostname);
386
571
  const page = await openCdpPage();
387
572
  try {
388
- await page.client.send('Network.setBlockedURLs', { urls: BLOCKED_ASSET_URLS }, page.sessionId);
389
- // Record the status of each frame's first Document response so we can reject HTTP errors after
390
- // the page settles. Keyed by frameId; the navigated top frame is matched via the navigate result.
391
- const documentResponses = new Map();
392
- page.client.on(`${page.sessionId}:Network.responseReceived`, (params) => {
393
- const { frameId } = params;
394
- const status = params.response?.status;
395
- // Only record real numeric statuses: a Document event without a usable status would
396
- // otherwise store NaN, which silently slips past the `>= 400` guard and the `?? 200`
397
- // fallback alike, leaking a NaN into BrowserFetchResult.status (declared `number`).
398
- if (params.type !== 'Document' || !frameId || typeof status !== 'number')
399
- return;
400
- if (documentResponses.has(frameId))
401
- return;
402
- documentResponses.set(frameId, {
403
- status,
404
- statusText: params.response?.statusText ?? '',
405
- });
573
+ return await captureNavigatedPage(page, currentUrl, {
574
+ timeout,
575
+ limit,
576
+ settleMs: options.settleMs ?? 1000,
406
577
  });
407
- const nav = await page.client.send('Page.navigate', { url: currentUrl }, page.sessionId);
408
- // A network-level failure (DNS, refused/closed connection, TLS) never fires a load event, so
409
- // fail fast here instead of waiting out the full navigation timeout on a dead page.
410
- if (nav.errorText) {
411
- throw describeNavigationFailure(nav.errorText, currentUrl);
412
- }
413
- await waitForLoad(page.client, page.sessionId, timeout, options.settleMs ?? 1000);
414
- const mainDoc = nav.frameId ? documentResponses.get(nav.frameId) : undefined;
415
- assertRenderedHttpOk(mainDoc);
416
- const evalResult = await page.client.send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true }, page.sessionId);
417
- const html = evalResult.result?.value;
418
- if (typeof html !== 'string') {
419
- throw new Error('Failed to retrieve rendered HTML from browser page.');
420
- }
421
- const byteLength = Buffer.byteLength(html);
422
- if (byteLength > limit) {
423
- throw new Error(`Response body size limit exceeded. Rendered HTML is ${byteLength} bytes.`);
424
- }
425
- return {
426
- status: mainDoc?.status ?? 200,
427
- contentType: 'text/html',
428
- etag: null,
429
- lastModified: null,
430
- finalUrl: currentUrl,
431
- responseSize: byteLength,
432
- content: html,
433
- };
434
578
  }
435
579
  finally {
436
580
  await page.close();
@@ -1,16 +1,19 @@
1
- import type { FetchResult } from './fetcher.js';
1
+ import { type FetchResult } from './fetcher.js';
2
2
  import type { ExtractionResult } from './extract.js';
3
3
  import type { CaptureMethod } from './schema.js';
4
4
  import type { MachineReadableArtifact, SiteCapabilities } from './docs/capabilities.js';
5
5
  import { type TextFetcher } from './docs/machine-readable.js';
6
+ /** Injected fetcher dependencies for `capturePage`. All three are required. */
6
7
  export interface CaptureDeps {
7
8
  fetchStatic: (url: string) => Promise<FetchResult>;
8
9
  fetchRendered: (url: string) => Promise<FetchResult>;
9
10
  fetchText: TextFetcher;
10
11
  }
12
+ /** Options passed to `capturePage` by the caller. */
11
13
  export interface CaptureOptions {
12
14
  forceRendered?: boolean;
13
15
  }
16
+ /** Full outcome of a `capturePage` call, including provenance and capability metadata. */
14
17
  export interface CaptureOutcome {
15
18
  fetchResult: FetchResult;
16
19
  extraction: ExtractionResult;
@@ -20,10 +23,29 @@ export interface CaptureOutcome {
20
23
  machineReadable: MachineReadableArtifact[];
21
24
  attemptedMethods: string[];
22
25
  }
23
- export declare function renderedFallbackNeeded(caps: SiteCapabilities, extraction: ExtractionResult | null): boolean;
26
+ /**
27
+ * Returns true when the static extraction result is missing or too weak to serve as the artifact,
28
+ * meaning the caller should attempt a rendered fallback.
29
+ *
30
+ * @param caps - Detected site capabilities; `recommendedCapture === 'rendered'` forces a fallback.
31
+ * @param extraction - Result of static extraction, or null when static fetch failed.
32
+ * @param staticError - The static fetch's failure, if any. A content-type policy rejection (JSON,
33
+ * PDF, binary) means the URL isn't a web page at all — a browser would just render its own
34
+ * built-in viewer for it, producing a corrupted-looking "extracted" artifact — so that specific
35
+ * failure is a hard stop, never a fallback trigger. `--rendered` still bypasses this entirely (it
36
+ * skips the static phase), so an informed retry remains available.
37
+ * @returns True when a rendered (browser) fallback should be attempted.
38
+ */
39
+ export declare function renderedFallbackNeeded(caps: SiteCapabilities, extraction: ExtractionResult | null, staticError?: Error | null): boolean;
24
40
  /**
25
41
  * Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
26
42
  * HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
27
43
  * provenance (capture method, source URL, machine-readable artifacts, attempted methods).
44
+ *
45
+ * @param url - The page URL to capture.
46
+ * @param options - Capture options (e.g. `forceRendered`).
47
+ * @param deps - Injected fetchers for static, rendered, and text requests.
48
+ * @returns Full CaptureOutcome including the best extraction and capture provenance.
49
+ * @throws {Error} When all capture strategies fail and no usable content is produced.
28
50
  */
29
51
  export declare function capturePage(url: string, options: CaptureOptions, deps: CaptureDeps): Promise<CaptureOutcome>;
@@ -1,3 +1,4 @@
1
+ import { isUnsupportedContentTypeError } from './fetcher.js';
1
2
  import { extractHtmlContent } from './extract.js';
2
3
  import { detectDocsEngine } from './docs/detect.js';
3
4
  import { emptyCapabilities } from './docs/capabilities.js';
@@ -6,9 +7,22 @@ import { mapUrlToSource } from './docs/source-map.js';
6
7
  import { extractFromSource } from './docs/markdown-source.js';
7
8
  import { validateTextArtifact } from './docs/validate.js';
8
9
  const MIN_USEFUL_CHARS = 600;
9
- // A static extraction needs rendering help when it failed outright, the detector says the page is
10
- // an SPA/client-rendered, or the extracted article is too thin/low-confidence to be useful.
11
- export function renderedFallbackNeeded(caps, extraction) {
10
+ /**
11
+ * Returns true when the static extraction result is missing or too weak to serve as the artifact,
12
+ * meaning the caller should attempt a rendered fallback.
13
+ *
14
+ * @param caps - Detected site capabilities; `recommendedCapture === 'rendered'` forces a fallback.
15
+ * @param extraction - Result of static extraction, or null when static fetch failed.
16
+ * @param staticError - The static fetch's failure, if any. A content-type policy rejection (JSON,
17
+ * PDF, binary) means the URL isn't a web page at all — a browser would just render its own
18
+ * built-in viewer for it, producing a corrupted-looking "extracted" artifact — so that specific
19
+ * failure is a hard stop, never a fallback trigger. `--rendered` still bypasses this entirely (it
20
+ * skips the static phase), so an informed retry remains available.
21
+ * @returns True when a rendered (browser) fallback should be attempted.
22
+ */
23
+ export function renderedFallbackNeeded(caps, extraction, staticError = null) {
24
+ if (staticError && isUnsupportedContentTypeError(staticError))
25
+ return false;
12
26
  if (!extraction)
13
27
  return true;
14
28
  if (caps.recommendedCapture === 'rendered')
@@ -116,7 +130,7 @@ async function sourceOutcome(url, phase, deps, attemptedMethods) {
116
130
  // is warranted; throws only when rendering fails and nothing usable was produced (preferring the
117
131
  // earlier static error).
118
132
  async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extraction) {
119
- if (!renderedFallbackNeeded(phase.capabilities, extraction))
133
+ if (!renderedFallbackNeeded(phase.capabilities, extraction, phase.staticError))
120
134
  return null;
121
135
  try {
122
136
  return await renderedOutcome(url, deps, attemptedMethods, phase.machineReadable);
@@ -131,6 +145,12 @@ async function renderedFallbackOutcome(url, deps, attemptedMethods, phase, extra
131
145
  * Captures a page's best-available content. Order: forced rendered → route/source Markdown → static
132
146
  * HTML → rendered fallback. Returns the chosen fetch result, extraction, detected capabilities, and
133
147
  * provenance (capture method, source URL, machine-readable artifacts, attempted methods).
148
+ *
149
+ * @param url - The page URL to capture.
150
+ * @param options - Capture options (e.g. `forceRendered`).
151
+ * @param deps - Injected fetchers for static, rendered, and text requests.
152
+ * @returns Full CaptureOutcome including the best extraction and capture provenance.
153
+ * @throws {Error} When all capture strategies fail and no usable content is produced.
134
154
  */
135
155
  export async function capturePage(url, options, deps) {
136
156
  const attemptedMethods = [];
@@ -0,0 +1,40 @@
1
+ /** Options accepted by {@link CliIo.error} — the subset of oclif's `this.error` options Bonsai uses. */
2
+ export interface CliErrorOptions {
3
+ exit: number;
4
+ code?: string;
5
+ suggestions?: string[];
6
+ ref?: string;
7
+ }
8
+ /**
9
+ * Narrow command-context port passed from oclif commands into command services, so the services
10
+ * stay free of oclif imports and are testable without a framework harness.
11
+ */
12
+ export interface CliIo {
13
+ bin: string;
14
+ configDir: string | undefined;
15
+ dataDir: string;
16
+ cwd: string;
17
+ json: boolean;
18
+ warn(msg: string): void;
19
+ log(msg: string): void;
20
+ error(msg: string, opts: CliErrorOptions): never;
21
+ /**
22
+ * Print one row of a batch failure with the same "Error: … / Code: … / Try this: …" rendering as
23
+ * {@link error}, but without exiting the process — a batch (`fetch url1 url2`) must keep
24
+ * processing the remaining rows after one fails. Using `warn` for this mislabels a real failure
25
+ * as non-fatal even though the row still flips the command's exit code and JSON `ok` to false.
26
+ */
27
+ errorRow(msg: string, opts: Omit<CliErrorOptions, 'exit'>): void;
28
+ }
29
+ /**
30
+ * Single exit point for a URL that failed normalization, shared by BaseCommand and the command
31
+ * services. A scheme-less but domain-shaped input reports MISSING_URL_SCHEME — the same code the
32
+ * root `bonsai <url>` shorthand uses — so an agent sees one stable code for "forgot https://"
33
+ * everywhere; truly unparseable input stays INVALID_URL.
34
+ *
35
+ * @param error - `this.error` (oclif) or `io.error`; must not return.
36
+ * @param url - Raw URL string the user supplied.
37
+ * @param message - Human-readable failure message.
38
+ * @returns Never — always exits via `error`.
39
+ */
40
+ export declare function failInvalidUrl(error: (msg: string, opts: CliErrorOptions) => never, url: string, message: string): never;