browserzap 0.4.0 → 0.5.5

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 };