dsh-builtin-browser 0.3.0 → 0.4.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.
@@ -19,6 +19,27 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import type {} from '@deepseek-ai/dsh-system-prompt'
20
20
  import type { BrowserSessionId } from '../browser/types.js'
21
21
 
22
+ /**
23
+ * Register a cleanup that runs once when the process exits.
24
+ *
25
+ * A single hook per process, holding a list of callbacks, rather than one listener per
26
+ * session: sessions come and go, listeners do not, and Node warns when a listener count
27
+ * passes ten.
28
+ */
29
+ const exitCleanups = new Set<() => void>()
30
+ let exitHookInstalled = false
31
+ function registerExitCleanup(cleanup: () => void): void {
32
+ exitCleanups.add(cleanup)
33
+ if (exitHookInstalled) return
34
+ exitHookInstalled = true
35
+ process.on('exit', () => {
36
+ for (const run of exitCleanups) {
37
+ try { run() } catch { /* exiting; nothing useful to do */ }
38
+ }
39
+ })
40
+ }
41
+
42
+
22
43
  /** Plugin name used by loader diagnostics. */
23
44
  export const name = 'tool-browser'
24
45
  /** The tool registry, browser seam, and system-prompt registry this tool layer consumes. */
@@ -41,17 +62,21 @@ interface ToolBrowserState {
41
62
  /** In-flight first-open per task key, so concurrent first calls share one session. */
42
63
  readonly pendingOpens: Map<string, Promise<BrowserSessionId>>
43
64
  /**
44
- * Action restriction: an allow-list of browser tool names, or undefined for
45
- * unrestricted. When set, any browser_* tool not in the list is refused
46
- * (browser_restrict itself is always allowed so the guard can be lifted).
47
- * Scoped to one plugin apply (one context), so a restriction set by one
48
- * task never leaks into another context.
65
+ * Action restriction per task, plus one from the plugin's own configuration.
66
+ *
67
+ * Two different things share this policy. A restriction from `cordis.patch.yml` is an
68
+ * operator decision about the whole plugin, so it applies to everyone and lives in
69
+ * `configRestrictedTo`. One set by `browser_restrict` is a decision by one task about
70
+ * its own work, so it is keyed by task — a single shared value let one task lock the
71
+ * browser tools of every other task, which is not what any caller asks for. Sessions
72
+ * beside it were already keyed this way; this was not.
49
73
  */
50
- restrictedTo: readonly string[] | undefined
74
+ configRestrictedTo: readonly string[] | undefined
75
+ restrictedByTask: Map<string, readonly string[]>
51
76
  }
52
77
 
53
78
  function createState(): ToolBrowserState {
54
- return { sessionsByTask: new Map(), pendingOpens: new Map(), restrictedTo: undefined }
79
+ return { sessionsByTask: new Map(), pendingOpens: new Map(), configRestrictedTo: undefined, restrictedByTask: new Map() }
55
80
  }
56
81
 
57
82
  /** Every live state, for the test hook (introspection only, never shared). */
@@ -63,11 +88,41 @@ const liveStates = new Set<ToolBrowserState>()
63
88
  * @param state - the calling context's tool state.
64
89
  * @param toolName - the browser tool about to run.
65
90
  */
66
- function assertAllowed(state: ToolBrowserState, toolName: string): void {
67
- const { restrictedTo } = state
68
- if (restrictedTo === undefined) return
69
- if (restrictedTo.includes(toolName)) return
70
- throw new Error(`browser action "${toolName}" is restricted (allow-list: ${restrictedTo.join(', ')})`)
91
+ /**
92
+ * Tools that only observe, plus the ones that undo a restriction.
93
+ *
94
+ * Never subject to an allow-list: restricting what the agent may *do* must not also
95
+ * blind it or trap it, and the tool descriptions promise as much. browser_restrict
96
+ * is in here so a task that restricted everything can still lift it.
97
+ */
98
+ export const READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
99
+ // Tools that only observe.
100
+ 'browser_snapshot', 'browser_a11y', 'browser_content', 'browser_scrape',
101
+ 'browser_screenshot', 'browser_get_value', 'browser_wait', 'browser_challenge',
102
+ 'browser_list_tabs', 'browser_session', 'browser_history', 'browser_visited',
103
+ // browser_auth reads cookies out of the browser: an observation, and one the
104
+ // credentials switch gates on its own (see credentials.allowRead in the settings).
105
+ 'browser_auth',
106
+ // Getting out of a bad state, or lifting the restriction itself. Without these a task
107
+ // that restricted everything could neither recover nor be released.
108
+ 'browser_restrict', 'browser_reset_session', 'browser_reset',
109
+ ])
110
+ function assertAllowed(state: ToolBrowserState, toolName: string, task: string): void {
111
+ // browser_restrict must stay usable, or a task that restricted everything
112
+ // could never lift it again.
113
+ // An allow-list is a statement about ACTIONS, so it must not take away the ability to
114
+ // look, or to get out of a bad state. The tool description and both READMEs promise
115
+ // that read-only tools are never blocked; before this they were, because only
116
+ // browser_restrict itself was exempt — a narrow list then locked browser_history,
117
+ // browser_reset_session and browser_auth away with no way back.
118
+ if (READ_ONLY_TOOLS.has(toolName) || toolName === 'browser_restrict') return
119
+ const config = state.configRestrictedTo
120
+ if (config !== undefined && !config.includes(toolName)) {
121
+ throw new Error(`browser action "${toolName}" is restricted by the plugin configuration (allow-list: ${config.join(', ')})`)
122
+ }
123
+ const allowed = state.restrictedByTask.get(task)
124
+ if (allowed === undefined || allowed.includes(toolName)) return
125
+ throw new Error(`browser action "${toolName}" is restricted (allow-list: ${allowed.join(', ')})`)
71
126
  }
72
127
 
73
128
  /** The agent view a tool execution carries (id + agent-scoped context). */
@@ -135,9 +190,10 @@ async function ensureSession(browser: NonNullable<Context['browser']>, state: To
135
190
  } else if (key === 'default') {
136
191
  // Agentless / CLI probe: close the session on process exit so the
137
192
  // window is not orphaned.
138
- process.on('exit', () => {
139
- void browser.close(session).catch(() => {})
140
- })
193
+ // One hook for the whole process, not one listener per session: a default session can
194
+ // be created repeatedly (a CLI probe does), and each registration lived for the life of
195
+ // the process — so the listener list grew without bound and Node warned at eleven.
196
+ registerExitCleanup(() => browser.close(session).catch(() => {}))
141
197
  }
142
198
  } catch (error) {
143
199
  // effect() failed — undo the registration so future calls retry.
@@ -157,7 +213,10 @@ async function ensureSession(browser: NonNullable<Context['browser']>, state: To
157
213
  function parseFillValue(v: string | undefined): string | number | boolean {
158
214
  if (v === 'true') return true
159
215
  if (v === 'false') return false
160
- if (v !== undefined && /^-?\d+(\.\d+)?$/.test(v) && String(Number(v)) === v) return Number(v)
216
+ // Deliberately NOT coerced to a number. The description says numbers are accepted in
217
+ // string form, and callers pass account numbers, postcodes and zero-padded ids through
218
+ // here: "007" became "7" and "1e3" became "1000", which silently rewrote the value they
219
+ // meant to type. A numeric-looking string is a string; the DOM decides how to store it.
161
220
  return v ?? ''
162
221
  }
163
222
 
@@ -200,7 +259,7 @@ export function apply(ctx: Context, config: Config = {}): void {
200
259
  liveStates.add(state)
201
260
  ctx.effect(() => () => { liveStates.delete(state) })
202
261
  // Re-apply resets the restriction: an omitted allowedActions lifts it.
203
- state.restrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined
262
+ state.configRestrictedTo = config.allowedActions !== undefined ? [...config.allowedActions] : undefined
204
263
 
205
264
  ctx.systemPrompt.section({
206
265
  name: 'tool:browser',
@@ -255,12 +314,12 @@ export function apply(ctx: Context, config: Config = {}): void {
255
314
  // browser_open is an overview of what just loaded, not a locator: it has no
256
315
  // `coords` parameter, so coordinates stay off here (browser_snapshot owns
257
316
  // that switch for callers that actually target pixels).
258
- render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: (args as { coords?: boolean }).coords === true }) }],
317
+ render: (args, value) => [{ type: 'text', text: formatSnapshot(value, {}) }],
259
318
  },
260
319
  timeoutMs,
261
320
  isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
262
321
  async execute(args, exec) {
263
- assertAllowed(state, 'browser_open')
322
+ assertAllowed(state, 'browser_open', taskKey(exec))
264
323
  const browser = ctx.get('browser')
265
324
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
266
325
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -525,7 +584,7 @@ export function apply(ctx: Context, config: Config = {}): void {
525
584
  timeoutMs,
526
585
  isConcurrencySafe: () => false, // page JS can be stateful
527
586
  async execute(args, exec) {
528
- assertAllowed(state, 'browser_execute')
587
+ assertAllowed(state, 'browser_execute', taskKey(exec))
529
588
  const browser = ctx.get('browser')
530
589
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
531
590
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -612,7 +671,7 @@ export function apply(ctx: Context, config: Config = {}): void {
612
671
  timeoutMs,
613
672
  isConcurrencySafe: () => true,
614
673
  async execute(args, exec) {
615
- assertAllowed(state, 'browser_scrape')
674
+ assertAllowed(state, 'browser_scrape', taskKey(exec))
616
675
  const browser = ctx.get('browser')
617
676
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
618
677
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -650,11 +709,17 @@ export function apply(ctx: Context, config: Config = {}): void {
650
709
  timeoutMs,
651
710
  isConcurrencySafe: () => false,
652
711
  async execute(args, exec) {
653
- assertAllowed(state, 'browser_click')
712
+ assertAllowed(state, 'browser_click', taskKey(exec))
654
713
  const browser = ctx.get('browser')
655
714
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
656
715
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
657
716
  const target = args.target as { by?: string; value?: string; index?: number } | undefined
717
+ // A target whose value is empty is a caller mistake, not a request to fall back.
718
+ // Degrading silently sent typed text to whatever held focus — possibly a password
719
+ // field — or dropped the semantic target in favour of coordinates.
720
+ if (target !== undefined && typeof target.value === 'string' && target.value === '') {
721
+ throw new Error('target.value is empty; pass a css selector, visible text or XPath, or omit target entirely to use x/y')
722
+ }
658
723
  if (target !== undefined && typeof target.value === 'string' && target.value !== '') {
659
724
  await browser.click(session, {
660
725
  target: {
@@ -696,11 +761,17 @@ export function apply(ctx: Context, config: Config = {}): void {
696
761
  timeoutMs,
697
762
  isConcurrencySafe: () => false,
698
763
  async execute(args, exec) {
699
- assertAllowed(state, 'browser_type')
764
+ assertAllowed(state, 'browser_type', taskKey(exec))
700
765
  const browser = ctx.get('browser')
701
766
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
702
767
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
703
768
  const target = args.target as { by?: string; value?: string; index?: number } | undefined
769
+ // A target whose value is empty is a caller mistake, not a request to fall back.
770
+ // Degrading silently sent typed text to whatever held focus — possibly a password
771
+ // field — or dropped the semantic target in favour of coordinates.
772
+ if (target !== undefined && typeof target.value === 'string' && target.value === '') {
773
+ throw new Error('target.value is empty; pass a css selector, visible text or XPath, or omit target entirely to use x/y')
774
+ }
704
775
  if (target !== undefined && typeof target.value === 'string' && target.value !== '') {
705
776
  await browser.type(session, {
706
777
  text: args.text,
@@ -734,7 +805,7 @@ export function apply(ctx: Context, config: Config = {}): void {
734
805
  timeoutMs,
735
806
  isConcurrencySafe: () => false, // mutates page scroll state; exclusive within a task
736
807
  async execute(args, exec) {
737
- assertAllowed(state, 'browser_scroll')
808
+ assertAllowed(state, 'browser_scroll', taskKey(exec))
738
809
  const browser = ctx.get('browser')
739
810
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
740
811
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -760,7 +831,7 @@ export function apply(ctx: Context, config: Config = {}): void {
760
831
  timeoutMs,
761
832
  isConcurrencySafe: () => false, // mutates page state; exclusive within a task
762
833
  async execute(_args, exec) {
763
- assertAllowed(state, 'browser_back')
834
+ assertAllowed(state, 'browser_back', taskKey(exec))
764
835
  const browser = ctx.get('browser')
765
836
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
766
837
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -780,7 +851,7 @@ export function apply(ctx: Context, config: Config = {}): void {
780
851
  timeoutMs,
781
852
  isConcurrencySafe: () => false, // mutates page state; exclusive within a task
782
853
  async execute(_args, exec) {
783
- assertAllowed(state, 'browser_forward')
854
+ assertAllowed(state, 'browser_forward', taskKey(exec))
784
855
  const browser = ctx.get('browser')
785
856
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
786
857
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -800,7 +871,7 @@ export function apply(ctx: Context, config: Config = {}): void {
800
871
  timeoutMs,
801
872
  isConcurrencySafe: () => false, // reloads the page; exclusive within a task
802
873
  async execute(_args, exec) {
803
- assertAllowed(state, 'browser_refresh')
874
+ assertAllowed(state, 'browser_refresh', taskKey(exec))
804
875
  const browser = ctx.get('browser')
805
876
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
806
877
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -822,7 +893,7 @@ export function apply(ctx: Context, config: Config = {}): void {
822
893
  timeoutMs,
823
894
  isConcurrencySafe: () => false, // mutates page state; exclusive within a task
824
895
  async execute(args, exec) {
825
- assertAllowed(state, 'browser_key')
896
+ assertAllowed(state, 'browser_key', taskKey(exec))
826
897
  const browser = ctx.get('browser')
827
898
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
828
899
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -847,7 +918,7 @@ export function apply(ctx: Context, config: Config = {}): void {
847
918
  label: { type: 'string', description: 'Match by associated <label> text or aria-label.' },
848
919
  placeholder: { type: 'string', description: 'Match by placeholder text.' },
849
920
  kind: { type: 'string', enum: ['text', 'textarea', 'checkbox', 'radio', 'select'], description: 'Field kind; defaults to text.' },
850
- value: { type: 'string', description: 'Value to set (string form; booleans/numbers accepted as strings).' },
921
+ value: { type: 'string', required: true, description: 'Value to set (string form; booleans/numbers accepted as strings). Required: omitting it would be read as "set empty", which unchecks a checkbox and clears a text field while still reporting success.' },
851
922
  },
852
923
  },
853
924
  },
@@ -891,18 +962,28 @@ export function apply(ctx: Context, config: Config = {}): void {
891
962
  timeoutMs,
892
963
  isConcurrencySafe: () => false,
893
964
  async execute(args, exec) {
894
- assertAllowed(state, 'browser_fill')
965
+ assertAllowed(state, 'browser_fill', taskKey(exec))
895
966
  const browser = ctx.get('browser')
896
967
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
897
968
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
898
- const fields = (args.fields ?? []).map((f: { selector?: string; name?: string; label?: string; placeholder?: string; kind?: string; value?: string }) => ({
899
- ...f.selector !== undefined ? { selector: f.selector } : {},
900
- ...f.name !== undefined ? { name: f.name } : {},
901
- ...f.label !== undefined ? { label: f.label } : {},
902
- ...f.placeholder !== undefined ? { placeholder: f.placeholder } : {},
903
- ...f.kind !== undefined ? { kind: f.kind as 'text' | 'textarea' | 'checkbox' | 'radio' | 'select' } : {},
904
- value: parseFillValue(f.value),
905
- }))
969
+ const fields = (args.fields ?? []).map((f: { selector?: string; name?: string; label?: string; placeholder?: string; kind?: string; value?: string }) => {
970
+ // A missing value is a caller mistake, not a request to clear the field. Turning
971
+ // it into '' silently unchecks a checkbox or empties a text input while the tool
972
+ // still reports success, so it fails loudly. The schema marks value required, but
973
+ // a call can still arrive without it.
974
+ if (f.value === undefined) {
975
+ const matcher = f.selector ?? f.name ?? f.label ?? f.placeholder ?? '(no matcher)'
976
+ throw new Error(`browser_fill: field "${matcher}" has no "value"; pass one (use "false" to uncheck, or "" to clear deliberately)`)
977
+ }
978
+ return {
979
+ ...f.selector !== undefined ? { selector: f.selector } : {},
980
+ ...f.name !== undefined ? { name: f.name } : {},
981
+ ...f.label !== undefined ? { label: f.label } : {},
982
+ ...f.placeholder !== undefined ? { placeholder: f.placeholder } : {},
983
+ ...f.kind !== undefined ? { kind: f.kind as 'text' | 'textarea' | 'checkbox' | 'radio' | 'select' } : {},
984
+ value: parseFillValue(f.value),
985
+ }
986
+ })
906
987
  const result = await browser.fillForm(session, {
907
988
  fields,
908
989
  ...args.submit === true ? { submit: true } : {},
@@ -950,7 +1031,7 @@ export function apply(ctx: Context, config: Config = {}): void {
950
1031
  timeoutMs,
951
1032
  isConcurrencySafe: () => false, // mutates a field; exclusive within a task
952
1033
  async execute(args, exec) {
953
- assertAllowed(state, 'browser_set_value')
1034
+ assertAllowed(state, 'browser_set_value', taskKey(exec))
954
1035
  const browser = ctx.get('browser')
955
1036
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
956
1037
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -981,7 +1062,7 @@ export function apply(ctx: Context, config: Config = {}): void {
981
1062
  timeoutMs,
982
1063
  isConcurrencySafe: () => false, // mutates a field; exclusive within a task
983
1064
  async execute(args, exec) {
984
- assertAllowed(state, 'browser_check')
1065
+ assertAllowed(state, 'browser_check', taskKey(exec))
985
1066
  const browser = ctx.get('browser')
986
1067
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
987
1068
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1021,7 +1102,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1021
1102
  timeoutMs,
1022
1103
  isConcurrencySafe: () => false, // mutates a field; exclusive within a task
1023
1104
  async execute(args, exec) {
1024
- assertAllowed(state, 'browser_select')
1105
+ assertAllowed(state, 'browser_select', taskKey(exec))
1025
1106
  const browser = ctx.get('browser')
1026
1107
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1027
1108
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1053,7 +1134,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1053
1134
  timeoutMs,
1054
1135
  isConcurrencySafe: () => false, // mutates a field; exclusive within a task
1055
1136
  async execute(args, exec) {
1056
- assertAllowed(state, 'browser_clear')
1137
+ assertAllowed(state, 'browser_clear', taskKey(exec))
1057
1138
  const browser = ctx.get('browser')
1058
1139
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1059
1140
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1094,7 +1175,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1094
1175
  timeoutMs,
1095
1176
  isConcurrencySafe: () => true,
1096
1177
  async execute(args, exec) {
1097
- assertAllowed(state, 'browser_get_value')
1178
+ assertAllowed(state, 'browser_get_value', taskKey(exec))
1098
1179
  const browser = ctx.get('browser')
1099
1180
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1100
1181
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1116,11 +1197,11 @@ export function apply(ctx: Context, config: Config = {}): void {
1116
1197
 
1117
1198
  ctx.tools.register(defineTool({
1118
1199
  name: 'browser_screenshot',
1119
- description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
1200
+ description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG needs a browser whose CDP encoder works — the self-hosted browser and an installed Chrome/Edge; the desktop sidebar falls back to PNG because the Electron CDP JPEG encoder hangs. Downscaling works on every carrier.',
1120
1201
  parameters: {
1121
1202
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1122
1203
  savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
1123
- format: { type: 'string', enum: ['png', 'jpeg'], description: 'Image format (default png; jpeg is self-hosted native path only).' },
1204
+ format: { type: 'string', enum: ['png', 'jpeg'], description: 'Image format (default png; jpeg needs the self-hosted browser or an installed Chrome/Edge; the desktop sidebar returns PNG because the Electron CDP JPEG encoder hangs).' },
1124
1205
  quality: { type: 'number', description: 'JPEG quality 1-100 (default 80); ignored for PNG.' },
1125
1206
  maxWidth: { type: 'number', description: 'Downscale to fit within this width (aspect preserved).' },
1126
1207
  maxHeight: { type: 'number', description: 'Downscale to fit within this height (aspect preserved).' },
@@ -1212,7 +1293,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1212
1293
  timeoutMs,
1213
1294
  isConcurrencySafe: () => false, // mutates the active tab; exclusive within a task
1214
1295
  async execute(args, exec) {
1215
- assertAllowed(state, 'browser_switch_tab')
1296
+ assertAllowed(state, 'browser_switch_tab', taskKey(exec))
1216
1297
  const browser = ctx.get('browser')
1217
1298
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1218
1299
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1234,7 +1315,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1234
1315
  timeoutMs,
1235
1316
  isConcurrencySafe: () => false, // mutates the tab list; exclusive within a task
1236
1317
  async execute(args, exec) {
1237
- assertAllowed(state, 'browser_close_tab')
1318
+ assertAllowed(state, 'browser_close_tab', taskKey(exec))
1238
1319
  const browser = ctx.get('browser')
1239
1320
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1240
1321
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1254,7 +1335,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1254
1335
  timeoutMs,
1255
1336
  isConcurrencySafe: () => false, // closes every tab; exclusive within a task
1256
1337
  async execute(_args, exec) {
1257
- assertAllowed(state, 'browser_reset')
1338
+ assertAllowed(state, 'browser_reset', taskKey(exec))
1258
1339
  const browser = ctx.get('browser')
1259
1340
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1260
1341
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1327,9 +1408,6 @@ export function apply(ctx: Context, config: Config = {}): void {
1327
1408
  if (e.action === 'replay' && params.of === 'type' && typeof params.text === 'string') {
1328
1409
  params.text = '*'.repeat(Math.min(params.text.length, 64)) + ` (${params.text.length} chars)`
1329
1410
  }
1330
- if (e.action === 'replay' && params.of === 'setValue' && typeof params.value === 'string') {
1331
- params.value = '*'.repeat(Math.min(params.value.length, 64)) + ` (${params.value.length} chars)`
1332
- }
1333
1411
  // Execute scripts may embed form tokens / credentials; mask script,
1334
1412
  // args, and result for both direct execute and replay-of-execute.
1335
1413
  const isExecute = e.action === 'execute' || (e.action === 'replay' && params.of === 'execute')
@@ -1446,7 +1524,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1446
1524
  timeoutMs,
1447
1525
  isConcurrencySafe: () => false,
1448
1526
  async execute(args, exec) {
1449
- assertAllowed(state, 'browser_replay')
1527
+ assertAllowed(state, 'browser_replay', taskKey(exec))
1450
1528
  const browser = ctx.get('browser')
1451
1529
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1452
1530
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1469,7 +1547,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1469
1547
  timeoutMs,
1470
1548
  isConcurrencySafe: () => false,
1471
1549
  async execute(args, exec) {
1472
- assertAllowed(state, 'browser_download')
1550
+ assertAllowed(state, 'browser_download', taskKey(exec))
1473
1551
  const browser = ctx.get('browser')
1474
1552
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1475
1553
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1530,7 +1608,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1530
1608
  timeoutMs,
1531
1609
  isConcurrencySafe: () => false, // closes the whole session; exclusive within a task
1532
1610
  async execute(_args, exec) {
1533
- assertAllowed(state, 'browser_reset_session')
1611
+ assertAllowed(state, 'browser_reset_session', taskKey(exec))
1534
1612
  const browser = ctx.get('browser')
1535
1613
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1536
1614
  const key = taskKey(exec)
@@ -1571,12 +1649,14 @@ export function apply(ctx: Context, config: Config = {}): void {
1571
1649
  if (unknown.length > 0) {
1572
1650
  throw new Error(`browser_restrict: unknown tool name(s) ${unknown.map(t => `"${t}"`).join(', ')} (must start with "browser_")`)
1573
1651
  }
1574
- // Empty list (or omitted) lifts the restriction; a non-empty list is the
1575
- // new allow-list. Scoped to this plugin apply (this Cordis context).
1576
- // In DSH each agent has its own context, so the restriction never leaks
1577
- // between tasks.
1578
- state.restrictedTo = allowed.length === 0 ? undefined : [...allowed]
1579
- return { restrictedTo: state.restrictedTo === undefined ? [] : [...state.restrictedTo] }
1652
+ // Empty list (or omitted) lifts this task's restriction; a non-empty list is its
1653
+ // new allow-list. Per task, not per plugin: this state object is shared by every
1654
+ // agent, so one value here used to lock every other task's browser tools.
1655
+ //
1656
+ if (allowed.length === 0) state.restrictedByTask.delete(taskKey(exec))
1657
+ else state.restrictedByTask.set(taskKey(exec), [...allowed])
1658
+ const current = state.restrictedByTask.get(taskKey(exec))
1659
+ return { restrictedTo: current === undefined ? [] : [...current] }
1580
1660
  },
1581
1661
  }))
1582
1662
 
@@ -1601,7 +1681,7 @@ export function apply(ctx: Context, config: Config = {}): void {
1601
1681
  timeoutMs,
1602
1682
  isConcurrencySafe: () => false,
1603
1683
  async execute(args, exec) {
1604
- assertAllowed(state, 'browser_auth')
1684
+ assertAllowed(state, 'browser_auth', taskKey(exec))
1605
1685
  const browser = ctx.get('browser')
1606
1686
  if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
1607
1687
  const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
@@ -1609,7 +1689,13 @@ export function apply(ctx: Context, config: Config = {}): void {
1609
1689
  const cookies = await browser.flushAuth(session)
1610
1690
  return { cookies: cookies.map(c => ({ ...c })) as never }
1611
1691
  }
1612
- const list = (args.cookies ?? []) as unknown[]
1692
+ // The description says cookies are required for restore; without this check an
1693
+ // empty call returned {restored: 0} and rendered 'Restored 0 cookies.', which
1694
+ // reads as success and would leave the caller believing the login was restored.
1695
+ if (!Array.isArray(args.cookies) || args.cookies.length === 0) {
1696
+ throw new Error('browser_auth: action "restore" needs a non-empty "cookies" array (get one from action "export" on the same profile)')
1697
+ }
1698
+ const list = args.cookies as unknown[]
1613
1699
  const restored = await browser.restoreAuth(session, list as never)
1614
1700
  return { restored }
1615
1701
  },