browserzap 0.4.0 → 0.5.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.
@@ -1,188 +1,188 @@
1
- /**
2
- * BrowserZap — session log: what every session did, for transparency and debugging.
3
- *
4
- * One JSON line per tool call or workflow step, appended to
5
- * <config dir>/logs/YYYY-MM-DD.jsonl (0600, kept 14 days)
6
- * by the MCP server (agent calls, agent-run workflows) and the bridge's panel
7
- * runner (workflows run from the side panel).
8
- *
9
- * Recorded: time, session and client, tool, the target as it was named, the
10
- * outcome (ok, state, error code), timing, and evidence useful for debugging
11
- * (waited-for-target time, click path, risk, navigation, where a workflow
12
- * stopped), plus the page as origin + path.
13
- *
14
- * Never recorded: typed values (only their length), parameter values (only
15
- * their names), URL query strings or fragments, page text, approval ids,
16
- * screenshots. Result summaries quote page content, so they are added only
17
- * with BROWSERZAP_LOG=verbose. BROWSERZAP_LOG=off disables the file.
18
- *
19
- * Read it with `npm run logs` (bridge-server/index.js --logs).
20
- */
21
-
22
- 'use strict';
23
-
24
- const fs = require('fs');
25
- const path = require('path');
26
-
27
- const { configDir } = require('./secret');
28
-
29
- const KEEP_DAYS = 14;
30
- const MAX_DAY_BYTES = 20 * 1024 * 1024;
31
- const MAX_TEXT = 80;
32
- const EVIDENCE_KEYS = [
33
- 'waitedForTargetMs', 'clickVia', 'input', 'risk', 'navigated', 'urlChanged', 'domChanged', 'noObservableChange',
34
- 'expectationMet', 'verified', 'redirected', 'crossOrigin', 'reResolvedStaleRef', 'elapsedMs',
35
- 'workflow', 'version', 'stoppedAt', 'completedSteps', 'totalSteps', 'completedRuns',
36
- // slow-server evidence (docs/AUDIT.md AU-02, AU-06, AU-07, AU-05)
37
- 'loaded', 'httpStatus', 'retryable', 'ttfbMs', 'domContentLoadedMs', 'loadMs', 'waitingFor', 'pageBusyMs', 'waitedForPersonMs',
38
- // why a step was slow or failed (AU-12); slowestRequest is origin + path only. busyIndicator is page text: verbose only.
39
- 'likelyCause', 'slowestRequest', 'pendingRequests', 'readyState', 'slowSteps',
40
- ];
41
- const PLAIN_ARGS = [
42
- 'key', 'direction', 'amount', 'timeoutMs', 'maxElements', 'includeText', 'maxTextChars', 'within', 'tabId',
43
- 'name', 'startAt', 'version', 'action', 'restoreVersion', 'limit', 'includeTab', 'waitMs', 'overwrite', 'keepValues',
44
- ];
45
-
46
- const mode = () => (process.env.BROWSERZAP_LOG || 'standard').toLowerCase();
47
- const logDir = () => path.join(configDir(), 'logs');
48
- const dayFile = (date = new Date()) => path.join(logDir(), `${date.toISOString().slice(0, 10)}.jsonl`);
49
- const clip = (v) => (typeof v === 'string' && v.length > MAX_TEXT ? `${v.slice(0, MAX_TEXT)}…` : v);
50
-
51
- /** origin + path only: query strings and fragments often carry tokens or personal data. */
52
- function safeUrl(raw) {
53
- if (typeof raw !== 'string' || !raw) return undefined;
54
- if (raw.includes('{{')) return clip(raw.split(/[?#]/)[0]);
55
- try {
56
- const u = new URL(raw);
57
- return `${u.origin}${u.pathname}`;
58
- } catch {
59
- return '(invalid url)';
60
- }
61
- }
62
-
63
- function targetOf(target) {
64
- if (!target || typeof target !== 'object') return undefined;
65
- const out = {};
66
- for (const key of ['ref', 'role', 'name', 'label', 'text', 'within']) if (typeof target[key] === 'string') out[key] = clip(target[key]);
67
- if (typeof target.href === 'string') out.href = safeUrl(target.href) ?? clip(target.href);
68
- return out;
69
- }
70
-
71
- /** What a call asked for, without values. */
72
- function describeArgs(args = {}) {
73
- if (!args || typeof args !== 'object') return {};
74
- const out = {};
75
- if (args.target) out.target = targetOf(args.target);
76
- if (args.expect && typeof args.expect === 'object') out.expect = Object.keys(args.expect);
77
- if (args.url !== undefined) out.url = safeUrl(args.url);
78
- if (args.value !== undefined) out.valueLength = String(args.value).length;
79
- if (args.option && typeof args.option === 'object') out.option = Object.keys(args.option);
80
- for (const key of PLAIN_ARGS) if (args[key] !== undefined && typeof args[key] !== 'object') out[key] = clip(args[key]);
81
- if (args.params && typeof args.params === 'object') out.params = Object.keys(args.params);
82
- if (Array.isArray(args.paramSets)) out.paramSets = args.paramSets.length;
83
- if (Array.isArray(args.steps)) out.steps = args.steps.length;
84
- if (args.approvalId) out.approval = true;
85
- if (typeof args.message === 'string') out.messageLength = args.message.length;
86
- return out;
87
- }
88
-
89
- /** What came back, without page text unless verbose. */
90
- function describeResult(env = {}) {
91
- const out = { ok: env.ok === true, state: env.state };
92
- if (env.error?.code) out.code = env.error.code;
93
- const page = safeUrl(env.page?.url);
94
- if (page) out.page = page;
95
- const evidence = {};
96
- for (const key of EVIDENCE_KEYS) if (env.evidence?.[key] !== undefined) evidence[key] = env.evidence[key];
97
- if (Object.keys(evidence).length) out.evidence = evidence;
98
- if (Array.isArray(env.candidates)) out.candidates = env.candidates.length;
99
- if (mode() === 'verbose') {
100
- if (env.evidence?.busyIndicator) (out.evidence ??= {}).busyIndicator = env.evidence.busyIndicator;
101
- out.summary = env.summary;
102
- if (env.nextAction) out.nextAction = env.nextAction;
103
- }
104
- return out;
105
- }
106
-
107
- let pruned = false;
108
- function prune() {
109
- if (pruned) return;
110
- pruned = true;
111
- const cutoff = Date.now() - KEEP_DAYS * 86_400_000;
112
- try {
113
- for (const file of fs.readdirSync(logDir())) {
114
- const day = Date.parse(file.slice(0, 10));
115
- if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(file) && day < cutoff) fs.unlinkSync(path.join(logDir(), file));
116
- }
117
- } catch { /* nothing to prune */ }
118
- }
119
-
120
- /**
121
- * @param {{ session: string, client: () => string }} who
122
- * @returns {{ write(entry: object): void, file(): string }}
123
- */
124
- /**
125
- * One file call per entry (audit AU-21): the folder is created once per
126
- * process and per folder, and the day file's size is read once per day file
127
- * and then tracked in memory.
128
- */
129
- let madeDir = null;
130
- function createSessionLog({ session, client }) {
131
- let full = false;
132
- let current = { file: null, size: 0 };
133
- function write(entry) {
134
- if (mode() === 'off') return;
135
- try {
136
- const dir = logDir();
137
- if (madeDir !== dir) {
138
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
139
- madeDir = dir;
140
- }
141
- prune();
142
- const file = dayFile();
143
- if (current.file !== file) {
144
- let size = 0;
145
- try {
146
- size = fs.statSync(file).size;
147
- } catch { /* a new day */ }
148
- current = { file, size };
149
- full = false;
150
- }
151
- if (current.size > MAX_DAY_BYTES) {
152
- if (!full) fs.appendFileSync(file, `${JSON.stringify({ ts: new Date().toISOString(), session, kind: 'log_full', note: 'Daily log limit reached; further entries today are dropped.' })}\n`);
153
- full = true;
154
- return;
155
- }
156
- const line = `${JSON.stringify({ ts: new Date().toISOString(), session, client: client(), ...entry })}\n`;
157
- fs.appendFileSync(file, line, { mode: 0o600 });
158
- current.size += Buffer.byteLength(line);
159
- } catch {
160
- // logging must never break a call; look at the folder again next time
161
- madeDir = null;
162
- }
163
- }
164
- return { write, file: () => dayFile() };
165
- }
166
-
167
- /** The last `limit` entries from the newest `days` daily files, optionally for one session. */
168
- function readLog({ days = 2, limit = 50, session } = {}) {
169
- const out = [];
170
- for (let i = days - 1; i >= 0; i--) {
171
- const file = dayFile(new Date(Date.now() - i * 86_400_000));
172
- let lines = [];
173
- try {
174
- lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
175
- } catch {
176
- continue;
177
- }
178
- for (const line of lines) {
179
- try {
180
- const entry = JSON.parse(line);
181
- if (!session || entry.session === session) out.push(entry);
182
- } catch { /* skip a torn line */ }
183
- }
184
- }
185
- return out.slice(-limit);
186
- }
187
-
188
- module.exports = { createSessionLog, readLog, describeArgs, describeResult, safeUrl, logDir, dayFile, KEEP_DAYS };
1
+ /**
2
+ * BrowserZap — session log: what every session did, for transparency and debugging.
3
+ *
4
+ * One JSON line per tool call or workflow step, appended to
5
+ * <config dir>/logs/YYYY-MM-DD.jsonl (0600, kept 14 days)
6
+ * by the MCP server (agent calls, agent-run workflows) and the bridge's panel
7
+ * runner (workflows run from the side panel).
8
+ *
9
+ * Recorded: time, session and client, tool, the target as it was named, the
10
+ * outcome (ok, state, error code), timing, and evidence useful for debugging
11
+ * (waited-for-target time, click path, risk, navigation, where a workflow
12
+ * stopped), plus the page as origin + path.
13
+ *
14
+ * Never recorded: typed values (only their length), parameter values (only
15
+ * their names), URL query strings or fragments, page text, approval ids,
16
+ * screenshots. Result summaries quote page content, so they are added only
17
+ * with BROWSERZAP_LOG=verbose. BROWSERZAP_LOG=off disables the file.
18
+ *
19
+ * Read it with `npm run logs` (bridge-server/index.js --logs).
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const { configDir } = require('./secret');
28
+
29
+ const KEEP_DAYS = 14;
30
+ const MAX_DAY_BYTES = 20 * 1024 * 1024;
31
+ const MAX_TEXT = 80;
32
+ const EVIDENCE_KEYS = [
33
+ 'waitedForTargetMs', 'clickVia', 'input', 'risk', 'navigated', 'urlChanged', 'domChanged', 'noObservableChange',
34
+ 'expectationMet', 'verified', 'redirected', 'crossOrigin', 'reResolvedStaleRef', 'elapsedMs',
35
+ 'workflow', 'version', 'stoppedAt', 'completedSteps', 'totalSteps', 'completedRuns',
36
+ // slow-server evidence (docs/AUDIT.md AU-02, AU-06, AU-07, AU-05)
37
+ 'loaded', 'httpStatus', 'retryable', 'ttfbMs', 'domContentLoadedMs', 'loadMs', 'waitingFor', 'pageBusyMs', 'waitedForPersonMs',
38
+ // why a step was slow or failed (AU-12); slowestRequest is origin + path only. busyIndicator is page text: verbose only.
39
+ 'likelyCause', 'slowestRequest', 'pendingRequests', 'readyState', 'slowSteps',
40
+ ];
41
+ const PLAIN_ARGS = [
42
+ 'key', 'direction', 'amount', 'timeoutMs', 'maxElements', 'includeText', 'maxTextChars', 'within', 'tabId',
43
+ 'name', 'startAt', 'version', 'action', 'restoreVersion', 'limit', 'includeTab', 'waitMs', 'overwrite', 'keepValues',
44
+ ];
45
+
46
+ const mode = () => (process.env.BROWSERZAP_LOG || 'standard').toLowerCase();
47
+ const logDir = () => path.join(configDir(), 'logs');
48
+ const dayFile = (date = new Date()) => path.join(logDir(), `${date.toISOString().slice(0, 10)}.jsonl`);
49
+ const clip = (v) => (typeof v === 'string' && v.length > MAX_TEXT ? `${v.slice(0, MAX_TEXT)}…` : v);
50
+
51
+ /** origin + path only: query strings and fragments often carry tokens or personal data. */
52
+ function safeUrl(raw) {
53
+ if (typeof raw !== 'string' || !raw) return undefined;
54
+ if (raw.includes('{{')) return clip(raw.split(/[?#]/)[0]);
55
+ try {
56
+ const u = new URL(raw);
57
+ return `${u.origin}${u.pathname}`;
58
+ } catch {
59
+ return '(invalid url)';
60
+ }
61
+ }
62
+
63
+ function targetOf(target) {
64
+ if (!target || typeof target !== 'object') return undefined;
65
+ const out = {};
66
+ for (const key of ['ref', 'role', 'name', 'label', 'text', 'within']) if (typeof target[key] === 'string') out[key] = clip(target[key]);
67
+ if (typeof target.href === 'string') out.href = safeUrl(target.href) ?? clip(target.href);
68
+ return out;
69
+ }
70
+
71
+ /** What a call asked for, without values. */
72
+ function describeArgs(args = {}) {
73
+ if (!args || typeof args !== 'object') return {};
74
+ const out = {};
75
+ if (args.target) out.target = targetOf(args.target);
76
+ if (args.expect && typeof args.expect === 'object') out.expect = Object.keys(args.expect);
77
+ if (args.url !== undefined) out.url = safeUrl(args.url);
78
+ if (args.value !== undefined) out.valueLength = String(args.value).length;
79
+ if (args.option && typeof args.option === 'object') out.option = Object.keys(args.option);
80
+ for (const key of PLAIN_ARGS) if (args[key] !== undefined && typeof args[key] !== 'object') out[key] = clip(args[key]);
81
+ if (args.params && typeof args.params === 'object') out.params = Object.keys(args.params);
82
+ if (Array.isArray(args.paramSets)) out.paramSets = args.paramSets.length;
83
+ if (Array.isArray(args.steps)) out.steps = args.steps.length;
84
+ if (args.approvalId) out.approval = true;
85
+ if (typeof args.message === 'string') out.messageLength = args.message.length;
86
+ return out;
87
+ }
88
+
89
+ /** What came back, without page text unless verbose. */
90
+ function describeResult(env = {}) {
91
+ const out = { ok: env.ok === true, state: env.state };
92
+ if (env.error?.code) out.code = env.error.code;
93
+ const page = safeUrl(env.page?.url);
94
+ if (page) out.page = page;
95
+ const evidence = {};
96
+ for (const key of EVIDENCE_KEYS) if (env.evidence?.[key] !== undefined) evidence[key] = env.evidence[key];
97
+ if (Object.keys(evidence).length) out.evidence = evidence;
98
+ if (Array.isArray(env.candidates)) out.candidates = env.candidates.length;
99
+ if (mode() === 'verbose') {
100
+ if (env.evidence?.busyIndicator) (out.evidence ??= {}).busyIndicator = env.evidence.busyIndicator;
101
+ out.summary = env.summary;
102
+ if (env.nextAction) out.nextAction = env.nextAction;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ let pruned = false;
108
+ function prune() {
109
+ if (pruned) return;
110
+ pruned = true;
111
+ const cutoff = Date.now() - KEEP_DAYS * 86_400_000;
112
+ try {
113
+ for (const file of fs.readdirSync(logDir())) {
114
+ const day = Date.parse(file.slice(0, 10));
115
+ if (/^\d{4}-\d{2}-\d{2}\.jsonl$/.test(file) && day < cutoff) fs.unlinkSync(path.join(logDir(), file));
116
+ }
117
+ } catch { /* nothing to prune */ }
118
+ }
119
+
120
+ /**
121
+ * @param {{ session: string, client: () => string }} who
122
+ * @returns {{ write(entry: object): void, file(): string }}
123
+ */
124
+ /**
125
+ * One file call per entry (audit AU-21): the folder is created once per
126
+ * process and per folder, and the day file's size is read once per day file
127
+ * and then tracked in memory.
128
+ */
129
+ let madeDir = null;
130
+ function createSessionLog({ session, client }) {
131
+ let full = false;
132
+ let current = { file: null, size: 0 };
133
+ function write(entry) {
134
+ if (mode() === 'off') return;
135
+ try {
136
+ const dir = logDir();
137
+ if (madeDir !== dir) {
138
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
139
+ madeDir = dir;
140
+ }
141
+ prune();
142
+ const file = dayFile();
143
+ if (current.file !== file) {
144
+ let size = 0;
145
+ try {
146
+ size = fs.statSync(file).size;
147
+ } catch { /* a new day */ }
148
+ current = { file, size };
149
+ full = false;
150
+ }
151
+ if (current.size > MAX_DAY_BYTES) {
152
+ if (!full) fs.appendFileSync(file, `${JSON.stringify({ ts: new Date().toISOString(), session, kind: 'log_full', note: 'Daily log limit reached; further entries today are dropped.' })}\n`);
153
+ full = true;
154
+ return;
155
+ }
156
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), session, client: client(), ...entry })}\n`;
157
+ fs.appendFileSync(file, line, { mode: 0o600 });
158
+ current.size += Buffer.byteLength(line);
159
+ } catch {
160
+ // logging must never break a call; look at the folder again next time
161
+ madeDir = null;
162
+ }
163
+ }
164
+ return { write, file: () => dayFile() };
165
+ }
166
+
167
+ /** The last `limit` entries from the newest `days` daily files, optionally for one session. */
168
+ function readLog({ days = 2, limit = 50, session } = {}) {
169
+ const out = [];
170
+ for (let i = days - 1; i >= 0; i--) {
171
+ const file = dayFile(new Date(Date.now() - i * 86_400_000));
172
+ let lines = [];
173
+ try {
174
+ lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
175
+ } catch {
176
+ continue;
177
+ }
178
+ for (const line of lines) {
179
+ try {
180
+ const entry = JSON.parse(line);
181
+ if (!session || entry.session === session) out.push(entry);
182
+ } catch { /* skip a torn line */ }
183
+ }
184
+ }
185
+ return out.slice(-limit);
186
+ }
187
+
188
+ module.exports = { createSessionLog, readLog, describeArgs, describeResult, safeUrl, logDir, dayFile, KEEP_DAYS };
@@ -289,7 +289,9 @@ const MCP_TOOLS = [
289
289
  },
290
290
  {
291
291
  name: 'browser_new_tab',
292
- description: 'Open a new tab in this session\'s tab group and make it the session tab.',
292
+ description: 'Open a helper tab in this session\'s tab group and make it the session tab. Only for a side trip ' +
293
+ '(research, a comparison): for a new task, navigate the main tab instead. Close the helper with browser_close_tab ' +
294
+ 'when done. At 5 tabs, the least recently used helper is closed to make room.',
293
295
  inputSchema: {
294
296
  type: 'object',
295
297
  additionalProperties: false,
@@ -311,6 +313,16 @@ const MCP_TOOLS = [
311
313
  properties: { tabId: { type: 'integer', minimum: 0 } },
312
314
  },
313
315
  },
316
+ {
317
+ name: 'browser_close_tab',
318
+ description: 'Close a helper tab of this session (the current tab when tabId is omitted) and go back to the main ' +
319
+ 'tab. The main tab cannot be closed: navigate it for the next task.',
320
+ inputSchema: {
321
+ type: 'object',
322
+ additionalProperties: false,
323
+ properties: { tabId: { type: 'integer', minimum: 0 } },
324
+ },
325
+ },
314
326
  {
315
327
  name: 'browser_set_session_name',
316
328
  description: 'Name this session; the name appears on the Chrome tab group so the user knows what it is doing.',
@@ -534,6 +546,7 @@ const ANNOTATIONS = {
534
546
  browser_new_tab: ['New tab', { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }],
535
547
  browser_list_tabs: ['List tabs', LOCAL_READ],
536
548
  browser_switch_tab: ['Switch tab', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }],
549
+ browser_close_tab: ['Close helper tab', { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }],
537
550
  browser_set_session_name: ['Name session', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }],
538
551
  browser_list_profiles: ['List Chrome profiles', LOCAL_READ],
539
552
  browser_select_profile: ['Choose Chrome profile', { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }],
@@ -577,6 +590,7 @@ const TOOL_TO_ACTION = {
577
590
  browser_new_tab: 'new_tab',
578
591
  browser_list_tabs: 'list_tabs',
579
592
  browser_switch_tab: 'switch_tab',
593
+ browser_close_tab: 'close_tab',
580
594
  };
581
595
 
582
596
  /**