@anweat/dsh-browser 0.1.6 → 0.1.8

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 (55) hide show
  1. package/README.md +306 -229
  2. package/cordis.patch.yml +26 -15
  3. package/lib/approval-policy.d.ts +2 -1
  4. package/lib/approval-policy.js +46 -1
  5. package/lib/approval-policy.js.map +1 -1
  6. package/lib/browser-service.d.ts +69 -19
  7. package/lib/browser-service.js +231 -64
  8. package/lib/browser-service.js.map +1 -1
  9. package/lib/client/SettingsCard.d.ts +2 -0
  10. package/lib/client/SettingsCard.js +21 -0
  11. package/lib/client/SettingsCard.js.map +1 -0
  12. package/lib/client/context-types.d.ts +8 -0
  13. package/lib/client/context-types.js +2 -0
  14. package/lib/client/context-types.js.map +1 -0
  15. package/lib/client/form.d.ts +62 -0
  16. package/lib/client/form.js +198 -0
  17. package/lib/client/form.js.map +1 -0
  18. package/lib/client/index.d.ts +16 -0
  19. package/lib/client/index.js +18 -0
  20. package/lib/client/index.js.map +1 -0
  21. package/lib/client/locales.d.ts +58 -0
  22. package/lib/client/locales.js +55 -0
  23. package/lib/client/locales.js.map +1 -0
  24. package/lib/client/settings-namespace.d.ts +2 -0
  25. package/lib/client/settings-namespace.js +3 -0
  26. package/lib/client/settings-namespace.js.map +1 -0
  27. package/lib/client/styles.d.ts +37 -0
  28. package/lib/client/styles.js +25 -0
  29. package/lib/client/styles.js.map +1 -0
  30. package/lib/client.js +753 -0
  31. package/lib/client.js.map +1 -0
  32. package/lib/config.d.ts +14 -0
  33. package/lib/config.js +25 -0
  34. package/lib/config.js.map +1 -1
  35. package/lib/deps.d.ts +7 -1
  36. package/lib/deps.js +22 -6
  37. package/lib/deps.js.map +1 -1
  38. package/lib/freedom.d.ts +8 -0
  39. package/lib/freedom.js +52 -0
  40. package/lib/freedom.js.map +1 -0
  41. package/lib/index.d.ts +2 -0
  42. package/lib/index.js +19 -6
  43. package/lib/index.js.map +1 -1
  44. package/lib/opencli-catalog.d.ts +21 -0
  45. package/lib/opencli-catalog.js +49 -0
  46. package/lib/opencli-catalog.js.map +1 -0
  47. package/lib/scripts.js +27 -27
  48. package/lib/tools.js +111 -22
  49. package/lib/tools.js.map +1 -1
  50. package/lib/usage-policy.d.ts +49 -0
  51. package/lib/usage-policy.js +171 -0
  52. package/lib/usage-policy.js.map +1 -0
  53. package/package.json +133 -78
  54. package/scripts/check-client-bundle.mjs +15 -0
  55. package/scripts/clean-lib.mjs +3 -0
@@ -1,7 +1,26 @@
1
1
  /** Approval classification for multi-action and arbitrary-code browser tools. */
2
2
  import { recipeNeedsApproval } from "./automation.js";
3
3
  import { validateUserscript } from "./scripts.js";
4
- export function browserPolicyDecision(name, args) {
4
+ import { isBrowserToolExposed } from "./freedom.js";
5
+ const DIRECT_INTERACTIONS = new Set(['browser_click', 'browser_type', 'browser_scroll']);
6
+ const WEB_LOCAL_MUTATIONS = new Set(['web_cache_clear']);
7
+ export function browserPolicyDecision(name, args, mode = 'standard') {
8
+ if (!isBrowserToolExposed(name, mode) && name.startsWith('browser_')) {
9
+ return { kind: 'deny', reason: `Browser tool ${name} is disabled by automationMode=${mode}` };
10
+ }
11
+ if (DIRECT_INTERACTIONS.has(name)) {
12
+ if (mode === 'read-only')
13
+ return { kind: 'deny', reason: `Page interaction is disabled by automationMode=${mode}` };
14
+ if (mode === 'standard')
15
+ return { kind: 'ask', reason: 'Run a direct Playwright page interaction: ' + name };
16
+ }
17
+ if (name === 'browser_install') {
18
+ if (mode === 'read-only')
19
+ return { kind: 'deny', reason: `Browser installation is disabled by automationMode=${mode}` };
20
+ if (mode === 'unrestricted')
21
+ return { kind: 'allow' };
22
+ return { kind: 'ask', reason: 'Install Playwright Chromium into the shared browser cache' };
23
+ }
5
24
  if (name === 'browser_userscript_run') {
6
25
  const input = args;
7
26
  if (typeof input.source !== 'string' || typeof input.url !== 'string')
@@ -9,6 +28,10 @@ export function browserPolicyDecision(name, args) {
9
28
  const validation = validateUserscript(input.source, input.url);
10
29
  if (!validation.valid)
11
30
  return { kind: 'deny', reason: 'invalid external userscript: ' + validation.errors.join('; ') };
31
+ if (mode === 'read-only')
32
+ return { kind: 'deny', reason: `External userscripts are disabled by automationMode=${mode}` };
33
+ if (mode === 'unrestricted')
34
+ return { kind: 'allow' };
12
35
  const host = new URL(input.url).hostname;
13
36
  return {
14
37
  kind: 'ask',
@@ -18,6 +41,10 @@ export function browserPolicyDecision(name, args) {
18
41
  if (name === 'browser_opencli_run') {
19
42
  const input = args;
20
43
  const argv = Array.isArray(input.args) ? input.args.filter(value => typeof value === 'string') : [];
44
+ if (mode === 'read-only')
45
+ return { kind: 'deny', reason: `General OpenCLI commands are disabled by automationMode=${mode}` };
46
+ if (mode === 'unrestricted')
47
+ return { kind: 'allow' };
21
48
  return { kind: 'ask', reason: 'Run a general OpenCLI command with the logged-in Chrome profile: ' + (argv.slice(0, 3).join(' ') || '(empty)') };
22
49
  }
23
50
  if (name === 'browser_recipe_run') {
@@ -25,9 +52,27 @@ export function browserPolicyDecision(name, args) {
25
52
  const steps = Array.isArray(input.steps) ? input.steps : [];
26
53
  if (recipeNeedsApproval(steps)) {
27
54
  const actions = [...new Set(steps.map(step => step.type).filter(type => !['wait', 'extract', 'assert', 'screenshot'].includes(type)))];
55
+ if (mode === 'read-only')
56
+ return { kind: 'deny', reason: `Mutating recipes are disabled by automationMode=${mode}: ` + actions.join(', ') };
57
+ if (mode === 'autonomous' || mode === 'unrestricted')
58
+ return { kind: 'allow' };
28
59
  return { kind: 'ask', reason: 'Run a multi-step Playwright recipe with page mutations: ' + actions.join(', ') };
29
60
  }
30
61
  }
62
+ if (name === 'web_deps' && args?.action === 'install') {
63
+ if (mode === 'read-only')
64
+ return { kind: 'deny', reason: `Dependency installation is disabled by automationMode=${mode}` };
65
+ if (mode === 'unrestricted')
66
+ return { kind: 'allow' };
67
+ return { kind: 'ask', reason: 'Install an external Web Search Pro backend dependency' };
68
+ }
69
+ const webRuleAction = name === 'web_rule' ? args?.action : undefined;
70
+ if (WEB_LOCAL_MUTATIONS.has(name) || (name === 'web_rule' && ['upsert', 'remove', 'import'].includes(String(webRuleAction)))) {
71
+ if (mode === 'read-only')
72
+ return { kind: 'deny', reason: `Web Search Pro mutations are disabled by automationMode=${mode}` };
73
+ if (mode === 'standard')
74
+ return { kind: 'ask', reason: 'Modify Web Search Pro local state: ' + name };
75
+ }
31
76
  return { kind: 'allow' };
32
77
  }
33
78
  //# sourceMappingURL=approval-policy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"approval-policy.js","sourceRoot":"","sources":["../src/approval-policy.ts"],"names":[],"mappings":"AAAA,iFAAiF;AAEjF,OAAO,EAAE,mBAAmB,EAA0B,MAAM,iBAAiB,CAAA;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AAOjD,MAAM,UAAU,qBAAqB,CAAC,IAAY,EAAE,IAAa;IAC/D,IAAI,IAAI,KAAK,wBAAwB,EAAE,CAAC;QACtC,MAAM,KAAK,GAAG,IAA2C,CAAA;QACzD,IAAI,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,KAAK,CAAC,GAAG,KAAK,QAAQ;YAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,6CAA6C,EAAE,CAAA;QACrJ,MAAM,UAAU,GAAG,kBAAkB,CAAC,KAAK,CAAC,MAAM,EAAE,KAAK,CAAC,GAAG,CAAC,CAAA;QAC9D,IAAI,CAAC,UAAU,CAAC,KAAK;YAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,+BAA+B,GAAG,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAA;QACtH,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAA;QACxC,OAAO;YACL,IAAI,EAAE,KAAK;YACX,MAAM,EAAE,4BAA4B,UAAU,CAAC,QAAQ,CAAC,IAAI,MAAM,UAAU,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,IAAI,mBAAmB,UAAU,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;SACpK,CAAA;IACH,CAAC;IACD,IAAI,IAAI,KAAK,qBAAqB,EAAE,CAAC;QACnC,MAAM,KAAK,GAAG,IAA0B,CAAA;QACxC,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAa,CAAC,CAAC,CAAC,EAAE,CAAA;QAC/G,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,mEAAmE,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,SAAS,CAAC,EAAE,CAAA;IACjJ,CAAC;IACD,IAAI,IAAI,KAAK,oBAAoB,EAAE,CAAC;QAClC,MAAM,KAAK,GAAG,IAA2B,CAAA;QACzC,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAA4B,CAAC,CAAC,CAAC,EAAE,CAAA;QAClF,IAAI,mBAAmB,CAAC,KAAK,CAAC,EAAE,CAAC;YAC/B,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAA;YACtI,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,0DAA0D,GAAG,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAA;QACjH,CAAC;IACH,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,CAAA;AAC1B,CAAC"}
1
+ {"version":3,"file":"approval-policy.js","sourceRoot":"","sources":["../src/approval-policy.ts"],"names":[],"mappings":"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"}
@@ -22,6 +22,9 @@ import { type CliResult } from './deps.ts';
22
22
  import type { ResolvedConfig } from './config.ts';
23
23
  import { type BrowserRecipeStep, type RecipeStepResult } from './automation.ts';
24
24
  import { type UserscriptValidation } from './scripts.ts';
25
+ import { type AutomationMode } from './freedom.ts';
26
+ import { type OpencliCatalogFilter, type OpencliCatalogItem } from './opencli-catalog.ts';
27
+ import { UsageGovernor } from './usage-policy.ts';
25
28
  export interface RenderRule {
26
29
  hostname: string;
27
30
  contentSelectors: string[];
@@ -36,7 +39,7 @@ export interface RenderResult {
36
39
  export interface SnapshotResult {
37
40
  title: string;
38
41
  text: string;
39
- screenshotPath: string;
42
+ screenshotPath?: string;
40
43
  htmlPath: string;
41
44
  usedRule?: string;
42
45
  }
@@ -69,6 +72,58 @@ export interface ScriptRunResult {
69
72
  resultJson: string;
70
73
  truncated: boolean;
71
74
  }
75
+ export interface CrawlPage {
76
+ url: string;
77
+ title: string;
78
+ text: string;
79
+ depth: number;
80
+ status: number;
81
+ }
82
+ export interface CrawlResult {
83
+ pages: CrawlPage[];
84
+ errors: {
85
+ url: string;
86
+ depth: number;
87
+ error: string;
88
+ status?: number;
89
+ }[];
90
+ stats: {
91
+ pagesVisited: number;
92
+ queued: number;
93
+ elapsedMs: number;
94
+ waitMs: number;
95
+ backoffEvents: number;
96
+ };
97
+ warnings: string[];
98
+ }
99
+ export interface BrowserStatus {
100
+ enabled: boolean;
101
+ channel: string;
102
+ browserRuntime: 'playwright' | 'patchright';
103
+ runtimeWarnings: string[];
104
+ headless: boolean;
105
+ opencliEnabled: boolean;
106
+ automationMode: AutomationMode;
107
+ exposedTools: string[];
108
+ directInteractionPolicy: 'deny' | 'ask' | 'allow';
109
+ mutatingRecipePolicy: 'deny' | 'ask' | 'allow';
110
+ externalUserscriptPolicy: 'deny' | 'ask' | 'allow';
111
+ opencliRunPolicy: 'deny' | 'ask' | 'allow';
112
+ chromiumInstalled: boolean;
113
+ usagePolicy: ResolvedConfig['usagePolicy'];
114
+ usageGovernor: ReturnType<UsageGovernor['snapshot']>;
115
+ authProfiles: {
116
+ id: string;
117
+ allowedDomains: string[];
118
+ persistState: boolean;
119
+ }[];
120
+ rulePacks: string[];
121
+ builtinScripts: string[];
122
+ externalUserscriptsRequireApproval: boolean;
123
+ mutatingRecipesRequireApproval: boolean;
124
+ activeUrl?: string;
125
+ activeAuthProfile?: string;
126
+ }
72
127
  export declare class BrowserService {
73
128
  private readonly config;
74
129
  private browser;
@@ -78,11 +133,14 @@ export declare class BrowserService {
78
133
  private activeProfile?;
79
134
  private activeRulePack?;
80
135
  private readonly authProfiles;
136
+ private readonly usageGovernor;
137
+ private opencliCatalogCache?;
81
138
  constructor(config: ResolvedConfig);
82
139
  available(): boolean;
83
140
  private ensure;
84
141
  /** Run `playwright install chromium` from the bundled playwright CLI. */
85
142
  installChromium(): Promise<CliResult>;
143
+ private navigate;
86
144
  private transientContext;
87
145
  private persistAndClose;
88
146
  render(url: string, rules: readonly RenderRule[], opts?: {
@@ -98,6 +156,7 @@ export declare class BrowserService {
98
156
  maxChars?: number;
99
157
  authProfile?: string;
100
158
  rulePack?: string;
159
+ screenshot?: boolean;
101
160
  }): Promise<SnapshotResult>;
102
161
  searchResults(url: string, spec: PlatformSpec, opts?: {
103
162
  signal?: AbortSignal;
@@ -118,6 +177,14 @@ export declare class BrowserService {
118
177
  signal?: AbortSignal;
119
178
  }): Promise<CliResult>;
120
179
  opencliDoctor(signal?: AbortSignal): Promise<CliResult>;
180
+ opencliCatalog(filter?: OpencliCatalogFilter, signal?: AbortSignal): Promise<OpencliCatalogItem[]>;
181
+ crawl(startUrls: readonly string[], opts?: {
182
+ maxPages?: number;
183
+ maxDepth?: number;
184
+ sameOrigin?: boolean;
185
+ maxCharsPerPage?: number;
186
+ signal?: AbortSignal;
187
+ }): Promise<CrawlResult>;
121
188
  scriptCatalog(): {
122
189
  id: string;
123
190
  name: string;
@@ -168,23 +235,6 @@ export declare class BrowserService {
168
235
  signal?: AbortSignal;
169
236
  }): Promise<RecipeRunResult>;
170
237
  closePage(): Promise<void>;
171
- status(): Promise<{
172
- enabled: boolean;
173
- channel: string;
174
- headless: boolean;
175
- opencliEnabled: boolean;
176
- chromiumInstalled: boolean;
177
- authProfiles: {
178
- id: string;
179
- allowedDomains: string[];
180
- persistState: boolean;
181
- }[];
182
- rulePacks: string[];
183
- builtinScripts: string[];
184
- externalUserscriptsRequireApproval: true;
185
- mutatingRecipesRequireApproval: true;
186
- activeUrl?: string;
187
- activeAuthProfile?: string;
188
- }>;
238
+ status(): Promise<BrowserStatus>;
189
239
  close(): Promise<void>;
190
240
  }
@@ -21,59 +21,75 @@
21
21
  import fs from 'node:fs';
22
22
  import path from 'node:path';
23
23
  import crypto from 'node:crypto';
24
- import { loadPlaywright, runOpencli, runNode, playwrightCliPath } from "./deps.js";
24
+ import { browserRuntimeCliPath, loadBrowserRuntime, runOpencli, runNode } from "./deps.js";
25
25
  import { AuthProfileStore } from "./auth-profiles.js";
26
26
  import { applyRuleSteps, resolveRulePack } from "./rule-packs.js";
27
27
  import { runRecipe } from "./automation.js";
28
28
  import { BUILTIN_SCRIPTS, builtinScript, executeUserscript, validateUserscript } from "./scripts.js";
29
+ import { browserToolsForMode } from "./freedom.js";
30
+ import { filterOpencliCatalog, parseOpencliCatalog } from "./opencli-catalog.js";
31
+ import { UsageGovernor } from "./usage-policy.js";
29
32
  /** Rules-aware content extractor (runs in the page). */
30
- const EXTRACTOR_FN = `(ruleList) => {
31
- const doc = document
32
- const title = doc.title ? doc.title.trim() : ''
33
- let host = ''
34
- try { host = location.hostname } catch (e) {}
35
- const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
36
- let rule = null
37
- for (const r of ruleList) {
38
- const rh = norm(r.hostname)
39
- if (host === rh || host.endsWith('.' + rh)) rule = r
40
- }
41
- const pick = (selectors) => {
42
- for (const sel of selectors) {
43
- try {
44
- const el = doc.querySelector(sel)
45
- if (el && (el.textContent || '').trim().length > 40) return el
46
- } catch (e) {}
47
- }
48
- return null
49
- }
50
- const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
51
- if (content && rule && rule.removeSelectors) {
52
- for (const sel of rule.removeSelectors) {
53
- try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
54
- }
55
- }
56
- const text = content ? (content.innerText || content.textContent || '') : ''
57
- return { title, text, html: doc.documentElement.outerHTML.slice(0, 2000000), usedRule: rule ? rule.hostname : null }
33
+ const EXTRACTOR_FN = `(ruleList) => {
34
+ const doc = document
35
+ const title = doc.title ? doc.title.trim() : ''
36
+ let host = ''
37
+ try { host = location.hostname } catch (e) {}
38
+ const norm = (h) => { const x = h.toLowerCase(); return x.startsWith('www.') ? x.slice(4) : x }
39
+ let rule = null
40
+ for (const r of ruleList) {
41
+ const rh = norm(r.hostname)
42
+ if (host === rh || host.endsWith('.' + rh)) rule = r
43
+ }
44
+ const pick = (selectors) => {
45
+ for (const sel of selectors) {
46
+ try {
47
+ const el = doc.querySelector(sel)
48
+ if (el && (el.textContent || '').trim().length > 40) return el
49
+ } catch (e) {}
50
+ }
51
+ return null
52
+ }
53
+ const content = rule ? pick(rule.contentSelectors) : (pick(['article', 'main', '[role="main"]']) || doc.body)
54
+ if (content && rule && rule.removeSelectors) {
55
+ for (const sel of rule.removeSelectors) {
56
+ try { content.querySelectorAll(sel).forEach((el) => el.remove()) } catch (e) {}
57
+ }
58
+ }
59
+ const text = content ? (content.innerText || content.textContent || '') : ''
60
+ return { title, text, html: doc.documentElement.outerHTML.slice(0, 2000000), usedRule: rule ? rule.hostname : null }
58
61
  }`;
59
62
  /** Platform search-page list extractor (runs in the page). */
60
- const LIST_EXTRACTOR = `(spec) => {
61
- const items = []
62
- const nodes = document.querySelectorAll(spec.item)
63
- for (let i = 0; i < nodes.length && items.length < 20; i++) {
64
- const el = nodes[i]
65
- const titleEl = spec.title ? el.querySelector(spec.title) : null
66
- const linkEl = spec.link ? el.querySelector(spec.link) : null
67
- const textEl = spec.text ? el.querySelector(spec.text) : null
68
- const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
69
- let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
70
- if (url && url.startsWith('/')) url = location.origin + url
71
- if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
72
- const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
73
- if (!url || !title || title.length < 2) continue
74
- items.push({ url, title, snippet })
75
- }
76
- return items
63
+ const LIST_EXTRACTOR = `(spec) => {
64
+ const items = []
65
+ const nodes = document.querySelectorAll(spec.item)
66
+ for (let i = 0; i < nodes.length && items.length < 20; i++) {
67
+ const el = nodes[i]
68
+ const titleEl = spec.title ? el.querySelector(spec.title) : null
69
+ const linkEl = spec.link ? el.querySelector(spec.link) : null
70
+ const textEl = spec.text ? el.querySelector(spec.text) : null
71
+ const title = (titleEl ? titleEl.textContent : el.textContent || '').trim().replace(/\\s+/g, ' ')
72
+ let url = linkEl ? (linkEl.href || linkEl.getAttribute('href') || '') : ''
73
+ if (url && url.startsWith('/')) url = location.origin + url
74
+ if (!url && linkEl === null && titleEl) { const a = titleEl.closest ? titleEl.closest('a') : null; if (a) url = a.href }
75
+ const snippet = textEl ? (textEl.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 300) : ''
76
+ if (!url || !title || title.length < 2) continue
77
+ items.push({ url, title, snippet })
78
+ }
79
+ return items
80
+ }`;
81
+ const CRAWL_EXTRACTOR = `(maxChars) => {
82
+ const root = document.querySelector('article, main, [role="main"]') || document.body
83
+ const text = ((root && (root.innerText || root.textContent)) || '').replace(/\\n{3,}/g, '\\n\\n').trim().slice(0, maxChars)
84
+ const links = []
85
+ for (const anchor of document.querySelectorAll('a[href]')) {
86
+ try {
87
+ const url = new URL(anchor.href, location.href)
88
+ if ((url.protocol === 'http:' || url.protocol === 'https:') && !links.includes(url.href)) links.push(url.href)
89
+ if (links.length >= 500) break
90
+ } catch (e) {}
91
+ }
92
+ return { title: document.title || '', text, links }
77
93
  }`;
78
94
  function uid() {
79
95
  return crypto.randomUUID();
@@ -98,9 +114,12 @@ export class BrowserService {
98
114
  activeProfile;
99
115
  activeRulePack;
100
116
  authProfiles;
117
+ usageGovernor;
118
+ opencliCatalogCache;
101
119
  constructor(config) {
102
120
  this.config = config;
103
121
  this.authProfiles = new AuthProfileStore(config.authProfiles);
122
+ this.usageGovernor = new UsageGovernor(config.usagePolicy);
104
123
  }
105
124
  available() {
106
125
  return this.config.enabled;
@@ -110,7 +129,7 @@ export class BrowserService {
110
129
  return this.browser;
111
130
  if (!this.launching) {
112
131
  this.launching = (async () => {
113
- const pw = loadPlaywright();
132
+ const pw = loadBrowserRuntime(this.config.browserRuntime);
114
133
  const launchOptions = { headless: this.config.headless };
115
134
  if (this.config.channel)
116
135
  launchOptions.channel = this.config.channel;
@@ -127,7 +146,7 @@ export class BrowserService {
127
146
  this.browser = await pw.chromium.launch(launchOptions);
128
147
  }
129
148
  else {
130
- throw new Error('dsh-browser: chromium is not installed. Run the browser_install tool, or: node "' + playwrightCliPath() + '" install chromium');
149
+ throw new Error('dsh-browser: chromium is not installed for ' + this.config.browserRuntime + '. Run the browser_install tool, or: node "' + browserRuntimeCliPath(this.config.browserRuntime) + '" install chromium');
131
150
  }
132
151
  }
133
152
  else {
@@ -144,16 +163,35 @@ export class BrowserService {
144
163
  }
145
164
  /** Run `playwright install chromium` from the bundled playwright CLI. */
146
165
  installChromium() {
147
- return runNode(playwrightCliPath(), ['install', 'chromium'], { timeoutMs: 600_000, signal: undefined, maxOutput: 256 * 1024 });
166
+ return runNode(browserRuntimeCliPath(this.config.browserRuntime), ['install', 'chromium'], { timeoutMs: 600_000, signal: undefined, maxOutput: 256 * 1024 });
167
+ }
168
+ async navigate(page, url, options, signal) {
169
+ let response;
170
+ for (let attempt = 0; attempt <= this.config.usagePolicy.retryLimit; attempt++) {
171
+ response = await this.usageGovernor.run(url, () => page.goto(url, options), signal);
172
+ const status = Number(response?.status?.() ?? 0);
173
+ if (status >= 200 && status < 400)
174
+ this.usageGovernor.noteResponse(url, status);
175
+ if (![429, 502, 503, 504].includes(status))
176
+ return response;
177
+ const rawRetryAfter = String(response?.headers?.()?.['retry-after'] ?? '');
178
+ const retryAfterMs = /^\d+(?:\.\d+)?$/.test(rawRetryAfter)
179
+ ? Number(rawRetryAfter) * 1000
180
+ : (Number.isFinite(Date.parse(rawRetryAfter)) ? Math.max(Date.parse(rawRetryAfter) - Date.now(), 0) : undefined);
181
+ this.usageGovernor.noteResponse(url, status, retryAfterMs);
182
+ if (attempt === this.config.usagePolicy.retryLimit)
183
+ return response;
184
+ }
185
+ return response;
148
186
  }
149
187
  async transientContext(url, opts = {}) {
150
188
  const browser = await this.ensure();
151
- const profileId = opts.authProfile ?? this.config.defaultAuthProfile;
189
+ const profileId = opts.anonymous ? undefined : (opts.authProfile ?? this.config.defaultAuthProfile);
152
190
  const profile = profileId ? this.authProfiles.resolve(profileId, url) : undefined;
153
191
  const rulePack = resolveRulePack(this.config.rulePacks, opts.rulePack, url);
154
192
  const context = await browser.newContext(profile?.storageStatePath
155
193
  ? { storageState: profile.storageStatePath }
156
- : (this.config.storageStatePath ? { storageState: this.config.storageStatePath } : {}));
194
+ : (!opts.anonymous && this.config.storageStatePath ? { storageState: this.config.storageStatePath } : {}));
157
195
  try {
158
196
  if (rulePack?.initScriptPath)
159
197
  await context.addInitScript({ path: rulePack.initScriptPath });
@@ -191,7 +229,7 @@ export class BrowserService {
191
229
  signal?.addEventListener('abort', onAbort);
192
230
  try {
193
231
  page.setDefaultTimeout(20_000);
194
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25_000 });
232
+ await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 25_000 }, signal);
195
233
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
196
234
  await applyRuleSteps(page, session.rulePack);
197
235
  if (opts.waitMs)
@@ -224,21 +262,22 @@ export class BrowserService {
224
262
  signal?.addEventListener('abort', onAbort);
225
263
  fs.mkdirSync(opts.outDir, { recursive: true });
226
264
  const stamp = Date.now() + '-' + uid().slice(0, 8);
227
- const screenshotPath = path.join(opts.outDir, stamp + '.png');
265
+ const screenshotPath = opts.screenshot === false ? undefined : path.join(opts.outDir, stamp + '.png');
228
266
  const htmlPath = path.join(opts.outDir, stamp + '.html');
229
267
  try {
230
268
  page.setDefaultTimeout(25_000);
231
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
269
+ await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, signal);
232
270
  await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => { });
233
271
  await applyRuleSteps(page, session.rulePack);
234
- await page.screenshot({ path: screenshotPath, fullPage: true });
272
+ if (screenshotPath)
273
+ await page.screenshot({ path: screenshotPath, fullPage: true });
235
274
  const html = await page.content();
236
275
  fs.writeFileSync(htmlPath, html, 'utf8');
237
276
  const data = await evaluateExtractor(page, rules);
238
277
  return {
239
278
  title: String(data.title ?? ''),
240
279
  text: capText(String(data.text ?? '').replace(/\n{3,}/g, '\n\n').trim(), opts.maxChars ?? 200_000),
241
- screenshotPath,
280
+ ...screenshotPath ? { screenshotPath } : {},
242
281
  htmlPath,
243
282
  ...data.usedRule ? { usedRule: String(data.usedRule) } : {},
244
283
  };
@@ -265,7 +304,7 @@ export class BrowserService {
265
304
  signal?.addEventListener('abort', onAbort);
266
305
  try {
267
306
  page.setDefaultTimeout(25_000);
268
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
307
+ await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, signal);
269
308
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
270
309
  await applyRuleSteps(page, session.rulePack);
271
310
  if (opts.waitMs)
@@ -298,11 +337,121 @@ export class BrowserService {
298
337
  if (args.length < 1 || args.length > 40 || args.some(arg => typeof arg !== 'string' || arg.length > 2_000)) {
299
338
  return Promise.resolve({ code: -1, stdout: '', stderr: 'dsh-browser: OpenCLI requires 1 to 40 arguments, each at most 2000 characters', timedOut: false });
300
339
  }
301
- return runOpencli(args, { ...opts, signal: opts.signal });
340
+ // OpenCLI adapters can issue real site traffic outside Playwright, so they
341
+ // share the same approval-independent concurrency and burst buffer.
342
+ return this.usageGovernor.run('https://opencli.local/', () => runOpencli(args, { ...opts, signal: opts.signal }), opts.signal);
302
343
  }
303
344
  opencliDoctor(signal) {
304
345
  return this.opencli(['doctor'], { timeoutMs: 30_000, signal });
305
346
  }
347
+ async opencliCatalog(filter = {}, signal) {
348
+ if (!this.config.opencliEnabled)
349
+ throw new Error('dsh-browser: OpenCLI is disabled');
350
+ if (!this.opencliCatalogCache) {
351
+ const result = await this.opencli(['list', '-f', 'json'], { timeoutMs: 60_000, signal });
352
+ if (result.code !== 0 || result.timedOut)
353
+ throw new Error('OpenCLI catalog failed: ' + (result.stderr || result.stdout).slice(0, 500));
354
+ this.opencliCatalogCache = parseOpencliCatalog(result.stdout);
355
+ }
356
+ return filterOpencliCatalog(this.opencliCatalogCache, filter);
357
+ }
358
+ async crawl(startUrls, opts = {}) {
359
+ if (startUrls.length < 1 || startUrls.length > 5)
360
+ throw new Error('browser crawl requires 1 to 5 start URLs');
361
+ const normalized = startUrls.map(value => {
362
+ const url = new URL(value);
363
+ if (!['http:', 'https:'].includes(url.protocol))
364
+ throw new Error('browser crawl only supports HTTP(S) URLs');
365
+ url.hash = '';
366
+ return url.href;
367
+ });
368
+ const maxPages = opts.maxPages ?? this.config.usagePolicy.maxPagesPerRun;
369
+ const maxDepth = opts.maxDepth ?? this.config.usagePolicy.maxDepth;
370
+ if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > this.config.usagePolicy.maxPagesPerRun) {
371
+ throw new Error('browser crawl maxPages must be from 1 to configured usagePolicy.maxPagesPerRun (' + this.config.usagePolicy.maxPagesPerRun + ')');
372
+ }
373
+ if (!Number.isInteger(maxDepth) || maxDepth < 0 || maxDepth > this.config.usagePolicy.maxDepth) {
374
+ throw new Error('browser crawl maxDepth must be from 0 to configured usagePolicy.maxDepth (' + this.config.usagePolicy.maxDepth + ')');
375
+ }
376
+ const maxCharsPerPage = Math.min(Math.max(opts.maxCharsPerPage ?? 20_000, 1_000), 50_000);
377
+ const sameOrigin = opts.sameOrigin ?? true;
378
+ const allowedOrigins = new Set(normalized.map(value => new URL(value).origin));
379
+ const queue = normalized.map(url => ({ url, depth: 0 }));
380
+ const seen = new Set(normalized);
381
+ const pages = [];
382
+ const errors = [];
383
+ const before = this.usageGovernor.snapshot();
384
+ const started = Date.now();
385
+ const session = await this.transientContext(normalized[0], { anonymous: true });
386
+ try {
387
+ while (queue.length && pages.length + errors.length < maxPages) {
388
+ if (opts.signal?.aborted)
389
+ throw new Error('browser crawl aborted');
390
+ const item = queue.shift();
391
+ const page = await session.context.newPage();
392
+ try {
393
+ page.setDefaultTimeout(30_000);
394
+ const response = await this.navigate(page, item.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
395
+ const status = Number(response?.status?.() ?? 0);
396
+ if (sameOrigin && !allowedOrigins.has(new URL(page.url()).origin)) {
397
+ errors.push({ url: item.url, depth: item.depth, status, error: 'cross-origin redirect blocked: ' + page.url() });
398
+ continue;
399
+ }
400
+ if (status >= 400) {
401
+ errors.push({ url: item.url, depth: item.depth, status, error: 'HTTP ' + status });
402
+ continue;
403
+ }
404
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => { });
405
+ const data = await page.evaluate('(' + CRAWL_EXTRACTOR + ')(' + maxCharsPerPage + ')');
406
+ pages.push({ url: page.url(), title: String(data.title ?? ''), text: String(data.text ?? ''), depth: item.depth, status });
407
+ if (item.depth >= maxDepth)
408
+ continue;
409
+ for (const rawLink of Array.isArray(data.links) ? data.links : []) {
410
+ let link;
411
+ try {
412
+ link = new URL(rawLink);
413
+ link.hash = '';
414
+ }
415
+ catch {
416
+ continue;
417
+ }
418
+ if (sameOrigin && !allowedOrigins.has(link.origin))
419
+ continue;
420
+ const href = link.href;
421
+ if (seen.has(href) || seen.size >= maxPages * 25)
422
+ continue;
423
+ seen.add(href);
424
+ queue.push({ url: href, depth: item.depth + 1 });
425
+ }
426
+ }
427
+ catch (error) {
428
+ errors.push({ url: item.url, depth: item.depth, error: String(error).slice(0, 500) });
429
+ }
430
+ finally {
431
+ await page.close().catch(() => { });
432
+ }
433
+ }
434
+ }
435
+ finally {
436
+ await this.persistAndClose(session);
437
+ }
438
+ const after = this.usageGovernor.snapshot();
439
+ return {
440
+ pages,
441
+ errors,
442
+ stats: {
443
+ pagesVisited: pages.length + errors.length,
444
+ queued: queue.length,
445
+ elapsedMs: Date.now() - started,
446
+ waitMs: after.totalWaitMs - before.totalWaitMs,
447
+ backoffEvents: after.backoffEvents - before.backoffEvents,
448
+ },
449
+ warnings: [
450
+ 'Bounded crawl: respect each site\'s terms, robots directives, copyright, privacy, and applicable law.',
451
+ 'No-approval mode skips human confirmation only; concurrency, burst, page/depth budgets, and server-pressure backoff remain active.',
452
+ ],
453
+ };
454
+ }
306
455
  scriptCatalog() {
307
456
  return BUILTIN_SCRIPTS.map(script => ({
308
457
  id: script.id,
@@ -329,7 +478,7 @@ export class BrowserService {
329
478
  let timer;
330
479
  try {
331
480
  page.setDefaultTimeout(30_000);
332
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
481
+ await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, signal);
333
482
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
334
483
  await applyRuleSteps(page, session.rulePack);
335
484
  const timeoutMs = Math.min(Math.max(opts.timeoutMs ?? 15_000, 1_000), 30_000);
@@ -409,7 +558,7 @@ export class BrowserService {
409
558
  async open(url, opts = {}) {
410
559
  const page = await this.ensureActivePage(url, opts);
411
560
  page.setDefaultTimeout(30_000);
412
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
561
+ await this.navigate(page, url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
413
562
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
414
563
  await applyRuleSteps(page, this.activeRulePack);
415
564
  if (opts.waitMs)
@@ -452,7 +601,7 @@ export class BrowserService {
452
601
  const page = await this.ensureActivePage(opts.url, opts);
453
602
  if (opts.url) {
454
603
  page.setDefaultTimeout(30_000);
455
- await page.goto(opts.url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
604
+ await this.navigate(page, opts.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }, opts.signal);
456
605
  await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => { });
457
606
  await applyRuleSteps(page, this.activeRulePack);
458
607
  if (opts.waitMs)
@@ -485,23 +634,41 @@ export class BrowserService {
485
634
  async status() {
486
635
  let chromiumInstalled = false;
487
636
  try {
488
- const pw = loadPlaywright();
637
+ const pw = loadBrowserRuntime(this.config.browserRuntime);
489
638
  chromiumInstalled = !!pw.chromium.executablePath();
490
639
  }
491
640
  catch {
492
641
  chromiumInstalled = false;
493
642
  }
643
+ const runtimeWarnings = this.config.browserRuntime === 'patchright'
644
+ ? [
645
+ 'Patchright is Chromium-only and disables Playwright console APIs to avoid Runtime.enable detection.',
646
+ ...(this.config.channel !== 'chrome' || this.config.headless
647
+ ? ['Patchright stealth is strongest with channel=chrome and headless=false; current settings favor automation/test compatibility.']
648
+ : []),
649
+ ]
650
+ : [];
494
651
  return {
495
652
  enabled: this.config.enabled,
496
653
  channel: this.config.channel,
654
+ browserRuntime: this.config.browserRuntime,
655
+ runtimeWarnings,
497
656
  headless: this.config.headless,
498
657
  opencliEnabled: this.config.opencliEnabled,
658
+ automationMode: this.config.automationMode,
659
+ exposedTools: browserToolsForMode(this.config.automationMode),
660
+ directInteractionPolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
661
+ mutatingRecipePolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'standard' ? 'ask' : 'allow',
662
+ externalUserscriptPolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'unrestricted' ? 'allow' : 'ask',
663
+ opencliRunPolicy: this.config.automationMode === 'read-only' ? 'deny' : this.config.automationMode === 'unrestricted' ? 'allow' : 'ask',
499
664
  chromiumInstalled,
665
+ usagePolicy: this.config.usagePolicy,
666
+ usageGovernor: this.usageGovernor.snapshot(),
500
667
  authProfiles: this.authProfiles.list(),
501
668
  rulePacks: Object.keys(this.config.rulePacks).sort(),
502
669
  builtinScripts: BUILTIN_SCRIPTS.map(script => script.id),
503
- externalUserscriptsRequireApproval: true,
504
- mutatingRecipesRequireApproval: true,
670
+ externalUserscriptsRequireApproval: ['standard', 'autonomous'].includes(this.config.automationMode),
671
+ mutatingRecipesRequireApproval: this.config.automationMode === 'standard',
505
672
  ...(this.activePage && !this.activePage.isClosed() ? { activeUrl: this.activePage.url() } : {}),
506
673
  ...this.activeProfile ? { activeAuthProfile: this.activeProfile.id } : {},
507
674
  };