browserzap 0.5.0 → 0.6.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.
- package/CHANGELOG.md +51 -0
- package/README.md +53 -30
- package/bridge-server/background-runs.js +91 -4
- package/bridge-server/chrome-launcher.js +42 -1
- package/bridge-server/cli-run.js +2 -0
- package/bridge-server/downloads.js +173 -0
- package/bridge-server/executor.js +1 -1
- package/bridge-server/extension-id.js +25 -1
- package/bridge-server/feedback.js +255 -0
- package/bridge-server/index.js +29 -2
- package/bridge-server/limits.js +27 -1
- package/bridge-server/mcp-server.js +219 -18
- package/bridge-server/native-host.js +5 -3
- package/bridge-server/panel.js +21 -3
- package/bridge-server/probe.js +2 -1
- package/bridge-server/resolvers.js +287 -0
- package/bridge-server/server.js +2 -1
- package/bridge-server/setup.js +184 -27
- package/bridge-server/tools.js +153 -4
- package/bridge-server/workflows.js +298 -11
- package/extension/background.js +1 -1
- package/extension/engine.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/sidepanel.css +1 -1
- package/extension/sidepanel.html +23 -2
- package/extension/sidepanel.js +1 -1
- package/package.json +2 -1
|
@@ -24,8 +24,10 @@
|
|
|
24
24
|
const { WebSocket } = require('ws');
|
|
25
25
|
const readline = require('readline');
|
|
26
26
|
const crypto = require('crypto');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
27
29
|
|
|
28
|
-
const { getOrCreateSecret } = require('./secret');
|
|
30
|
+
const { getOrCreateSecret, configDir } = require('./secret');
|
|
29
31
|
const { BIND_HOST, DEFAULT_PORT, PROTOCOL_VERSION, sanitizeSessionName } = require('./server');
|
|
30
32
|
const { ensureBridge: ensureBridgeListening } = require('./ensure-bridge');
|
|
31
33
|
const { LIMITS, CALL_DEADLINE_MS, STATUS_DEADLINE_MS } = require('./limits');
|
|
@@ -37,7 +39,9 @@ const { createWorkflowService, checkStep, stepLogEntry } = require('./workflow-s
|
|
|
37
39
|
const { createSessionLog, describeArgs, describeResult } = require('./session-log');
|
|
38
40
|
const workflows = require('./workflows');
|
|
39
41
|
const screenshots = require('./screenshots');
|
|
40
|
-
const
|
|
42
|
+
const feedback = require('./feedback');
|
|
43
|
+
const downloads = require('./downloads');
|
|
44
|
+
const { createRunRegistry, compactBatch, probeResume, RunError } = require('./background-runs');
|
|
41
45
|
|
|
42
46
|
const { WorkflowError, Recorder } = workflows;
|
|
43
47
|
|
|
@@ -189,12 +193,12 @@ function connectToBridge() {
|
|
|
189
193
|
|
|
190
194
|
function describeBridgeProblem() {
|
|
191
195
|
if (lastCloseCode === 4003) {
|
|
192
|
-
return 'The bridge rejected this server\'s pairing token. Run "
|
|
196
|
+
return 'The bridge rejected this server\'s pairing token. Run "browserzap doctor" for details; "browserzap restart-bridge" replaces a bridge from another install.';
|
|
193
197
|
}
|
|
194
198
|
if (lastCloseCode === 4005) {
|
|
195
199
|
return `${lastCloseReason || 'The bridge speaks a different protocol version.'} Restart the bridge and this MCP server after rebuilding.`;
|
|
196
200
|
}
|
|
197
|
-
return 'The local BrowserZap bridge is not reachable. Run "
|
|
201
|
+
return 'The local BrowserZap bridge is not reachable. Run "browserzap doctor" and check that the extension is loaded in Chrome.';
|
|
198
202
|
}
|
|
199
203
|
|
|
200
204
|
function ensureSession(timeoutMs = 3000) {
|
|
@@ -227,7 +231,7 @@ function bridgeRequest(type, payload = {}, timeoutMs = 3000) {
|
|
|
227
231
|
const id = `${SESSION_ID}-b${++bridgeSeq}`;
|
|
228
232
|
const timer = setTimeout(() => {
|
|
229
233
|
bridgeReplies.delete(id);
|
|
230
|
-
reject(Object.assign(new Error('The bridge did not answer. It may be an older version: run "
|
|
234
|
+
reject(Object.assign(new Error('The bridge did not answer. It may be an older version: run "browserzap restart-bridge".'), { code: 'BRIDGE_DISCONNECTED' }));
|
|
231
235
|
}, timeoutMs);
|
|
232
236
|
bridgeReplies.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
|
|
233
237
|
ws.send(JSON.stringify({ type, id, ...payload }));
|
|
@@ -508,6 +512,13 @@ async function callToolWith(progress, requestId, toolName, args) {
|
|
|
508
512
|
|
|
509
513
|
if (toolName === 'browser_list_profiles' || toolName === 'browser_select_profile') return finish(await profileTool(traceId, toolName, args));
|
|
510
514
|
|
|
515
|
+
if (toolName === 'browser_downloads') return finish(await downloadsTool(traceId, args));
|
|
516
|
+
|
|
517
|
+
if (toolName === 'feedback_list' || toolName === 'feedback_get') {
|
|
518
|
+
const { env, extra } = feedbackTool(traceId, toolName, args);
|
|
519
|
+
return finish(env, extra);
|
|
520
|
+
}
|
|
521
|
+
|
|
511
522
|
if (LOCAL_TOOLS.has(toolName)) return finish(await workflowTool(requestId, traceId, toolName, args, progress));
|
|
512
523
|
// A background run is driving this session's tab: reading is fine, acting would interleave with its steps.
|
|
513
524
|
const running = backgroundRuns.active();
|
|
@@ -526,7 +537,19 @@ async function callToolWith(progress, requestId, toolName, args) {
|
|
|
526
537
|
const notReady = await ensureBrowserForCall(traceId, progress, () => call.cancelled);
|
|
527
538
|
if (notReady) return finish(notReady);
|
|
528
539
|
if (call.cancelled) return finish(errorEnvelope(traceId, 'CANCELLED', 'Cancelled by the client.'));
|
|
529
|
-
|
|
540
|
+
let toolArgs = args;
|
|
541
|
+
if (toolName === 'file_upload') {
|
|
542
|
+
const checked = checkUpload(traceId, args);
|
|
543
|
+
if (checked.error) return finish(checked.error);
|
|
544
|
+
toolArgs = checked.args;
|
|
545
|
+
}
|
|
546
|
+
// A download the agent starts is saved here, with no "Save as" dialog (dialogs.ts).
|
|
547
|
+
if (toolName === 'element_click' || toolName === 'browser_press_key') toolArgs = { ...toolArgs, downloadDir: downloadFolder() };
|
|
548
|
+
// A click or Enter may start a download: note the folder's names first (one directory read).
|
|
549
|
+
const watchDownloads = toolName === 'element_click' || toolName === 'browser_press_key';
|
|
550
|
+
const downloadsBefore = watchDownloads ? downloads.names(downloadFolder()) : null;
|
|
551
|
+
const { env, extra } = await executeTool(toolName, toolArgs, { onStart: (c) => { call.cancel = c.cancel; } });
|
|
552
|
+
if (downloadsBefore && env.ok) noteDownloads(env, downloadsBefore);
|
|
530
553
|
if (recorder) recorder.observe(toolName, args, env);
|
|
531
554
|
return finish(env, extra);
|
|
532
555
|
} finally {
|
|
@@ -552,6 +575,160 @@ async function executeTool(toolName, args, { allowedOrigins = ALLOWED_ORIGINS, o
|
|
|
552
575
|
return { env, extra };
|
|
553
576
|
}
|
|
554
577
|
|
|
578
|
+
// ─── Downloads (ROADMAP 0.5.4) ───────────────────────────────────────────────
|
|
579
|
+
|
|
580
|
+
/** When this MCP server started: browser_downloads lists what arrived since then by default. */
|
|
581
|
+
const SESSION_STARTED_AT = Date.now();
|
|
582
|
+
let downloadDirCache = null;
|
|
583
|
+
const downloadFolder = () => (downloadDirCache ??= downloads.downloadDir());
|
|
584
|
+
|
|
585
|
+
/** A file that appeared in the Downloads folder during a click is said in its result. */
|
|
586
|
+
function noteDownloads(env, before) {
|
|
587
|
+
const started = downloads.arrived(before, downloadFolder());
|
|
588
|
+
if (started.length === 0) return;
|
|
589
|
+
env.evidence = { ...(env.evidence ?? {}), downloads: started };
|
|
590
|
+
if (typeof env.summary === 'string') {
|
|
591
|
+
env.summary = `${env.summary} It started a download: ${started.map(downloads.describe).join(', ')}.`;
|
|
592
|
+
}
|
|
593
|
+
if (started.some((d) => d.state === 'in_progress') && !env.nextAction) {
|
|
594
|
+
env.nextAction = 'Call browser_downloads with waitForMs to wait until the download finishes.';
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function downloadsTool(traceId, args) {
|
|
599
|
+
const dir = downloadFolder();
|
|
600
|
+
let sinceMs = SESSION_STARTED_AT;
|
|
601
|
+
if (typeof args.since === 'string') {
|
|
602
|
+
const parsed = Date.parse(args.since);
|
|
603
|
+
if (Number.isNaN(parsed)) return errorEnvelope(traceId, 'INVALID_ARGUMENT', 'since must be an ISO date and time, e.g. 2026-10-29T10:00:00Z.');
|
|
604
|
+
sinceMs = parsed;
|
|
605
|
+
}
|
|
606
|
+
const waitMs = Number.isInteger(args.waitForMs) ? args.waitForMs : 0;
|
|
607
|
+
const { entries, timedOut } = waitMs > 0
|
|
608
|
+
? await downloads.waitFor(dir, sinceMs, waitMs)
|
|
609
|
+
: { entries: downloads.since(dir, sinceMs), timedOut: false };
|
|
610
|
+
const running = entries.filter((e) => e.state === 'in_progress').length;
|
|
611
|
+
let summary = entries.length === 0
|
|
612
|
+
? `No downloads in ${dir} since ${new Date(sinceMs).toISOString()}${timedOut ? ` (waited ${waitMs} ms)` : ''}.`
|
|
613
|
+
: `${entries.length} download(s)${running ? `, ${running} still in progress` : ', all complete'}: ${entries.slice(-5).map(downloads.describe).join(', ')}.`;
|
|
614
|
+
// Nothing arrived and Chrome asks where to save: the person is probably looking at a "Save as" dialog.
|
|
615
|
+
const asks = entries.length === 0 ? downloads.asksWhereToSave({ dataDir: chromeLauncher.userDataDir() }) : null;
|
|
616
|
+
if (asks) summary = `${summary} ${downloads.ASK_HINT}`;
|
|
617
|
+
return okEnvelope(traceId, summary, { downloads: entries, folder: dir }, { timedOut, ...(asks ? { chromeAsksWhereToSave: true } : {}) });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ─── File upload (ROADMAP 0.5.4) ─────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
623
|
+
|
|
624
|
+
/** What a file looks like when it must never leave the machine through file_upload, or null. */
|
|
625
|
+
function secretLooking(file) {
|
|
626
|
+
const lower = file.toLowerCase().replace(/\\/g, '/');
|
|
627
|
+
const name = path.basename(lower);
|
|
628
|
+
const config = configDir().toLowerCase().replace(/\\/g, '/');
|
|
629
|
+
if (lower.startsWith(`${config}/`)) return 'BrowserZap\'s own data (pairing secret, licence, workflows)';
|
|
630
|
+
if (/\/\.(ssh|gnupg|aws|azure|kube|docker|config\/gcloud)\//.test(lower)) return 'a credentials folder';
|
|
631
|
+
if (/\/(google\/chrome|microsoft\/edge|bravesoftware|mozilla\/firefox)\/(user data|profiles?)\//.test(lower)) return 'a browser profile (cookies, saved passwords)';
|
|
632
|
+
if (/^(id_(rsa|dsa|ecdsa|ed25519)(\.pub)?|known_hosts|authorized_keys|\.netrc|\.npmrc|\.pypirc|\.git-credentials|credentials(\.json)?|\.env(\..+)?)$/.test(name)) return 'a key or credentials file';
|
|
633
|
+
if (/\.(pem|key|p12|pfx|jks|keystore|kdbx|ppk|ovpn)$/.test(name)) return 'a private key or password store';
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Check a file_upload path before anything reaches the browser: absolute, an
|
|
639
|
+
* existing regular file, ≤ 50 MB, and inside BROWSERZAP_UPLOAD_DIR when that is
|
|
640
|
+
* set. The extension gets the resolved path plus the name and size it shows in
|
|
641
|
+
* the approval. The file's content is never read here.
|
|
642
|
+
*/
|
|
643
|
+
function checkUpload(traceId, args) {
|
|
644
|
+
const raw = String(args.path ?? '');
|
|
645
|
+
if (!path.isAbsolute(raw)) {
|
|
646
|
+
return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', 'path must be an absolute path on this computer.') };
|
|
647
|
+
}
|
|
648
|
+
let real;
|
|
649
|
+
let stat;
|
|
650
|
+
try {
|
|
651
|
+
real = fs.realpathSync(raw);
|
|
652
|
+
stat = fs.statSync(real);
|
|
653
|
+
} catch {
|
|
654
|
+
return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', `No file at ${raw}.`) };
|
|
655
|
+
}
|
|
656
|
+
if (!stat.isFile()) return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', `${raw} is not a file.`) };
|
|
657
|
+
// A page can try to talk the agent into uploading secrets (prompt injection).
|
|
658
|
+
// The person approves every file, and these are refused even then.
|
|
659
|
+
const secret = secretLooking(real);
|
|
660
|
+
if (secret) {
|
|
661
|
+
return { error: errorEnvelope(traceId, 'POLICY_BLOCKED', `${path.basename(real)} looks like ${secret}; file_upload never sends it.`, 'Choose the document the task needs, or ask the user to attach it themselves.') };
|
|
662
|
+
}
|
|
663
|
+
if (stat.size > MAX_UPLOAD_BYTES) {
|
|
664
|
+
return { error: errorEnvelope(traceId, 'INVALID_ARGUMENT', `${path.basename(real)} is ${Math.round(stat.size / 1048576)} MB; file_upload takes files up to 50 MB.`) };
|
|
665
|
+
}
|
|
666
|
+
const dir = process.env.BROWSERZAP_UPLOAD_DIR;
|
|
667
|
+
if (dir) {
|
|
668
|
+
let allowed;
|
|
669
|
+
try { allowed = fs.realpathSync(dir); } catch { allowed = path.resolve(dir); }
|
|
670
|
+
const rel = path.relative(allowed, real);
|
|
671
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
672
|
+
return { error: errorEnvelope(traceId, 'POLICY_BLOCKED', `Uploads are limited to ${allowed} (BROWSERZAP_UPLOAD_DIR).`) };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return { args: { ...args, path: real, fileName: path.basename(real), sizeBytes: stat.size } };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ─── Comment mode batches (docs/COMMENT_MODE.md) ─────────────────────────────
|
|
679
|
+
|
|
680
|
+
/** Inline images per feedback_get: enough to see every comment, small enough for any client. */
|
|
681
|
+
const FEEDBACK_INLINE_IMAGES = 12;
|
|
682
|
+
const FEEDBACK_INLINE_BYTES = 6 * 1024 * 1024;
|
|
683
|
+
const MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' };
|
|
684
|
+
|
|
685
|
+
function feedbackTool(traceId, toolName, args) {
|
|
686
|
+
if (toolName === 'feedback_list') {
|
|
687
|
+
const batches = feedback.list({ includeDelivered: args.includeDelivered === true });
|
|
688
|
+
const pending = batches.filter((b) => !b.delivered).length;
|
|
689
|
+
return {
|
|
690
|
+
env: okEnvelope(traceId, batches.length
|
|
691
|
+
? `${batches.length} comment batch(es)${pending ? `, ${pending} not yet read` : ''}.`
|
|
692
|
+
: 'No comments from the person. They add them with Comment mode in the BrowserZap side panel.', { batches }),
|
|
693
|
+
extra: [],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const batch = feedback.get(args.id);
|
|
697
|
+
if (!batch) {
|
|
698
|
+
return {
|
|
699
|
+
env: args.id
|
|
700
|
+
? errorEnvelope(traceId, 'INVALID_ARGUMENT', `No comment batch "${args.id}".`, 'Call feedback_list to see the batches.')
|
|
701
|
+
: okEnvelope(traceId, 'No unread comments from the person. They add them with Comment mode in the BrowserZap side panel, then press Send.', { batch: null }),
|
|
702
|
+
extra: [],
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
if (args.markDelivered !== false) feedback.markDelivered(batch.id);
|
|
706
|
+
const extra = [];
|
|
707
|
+
let bytes = 0;
|
|
708
|
+
if (args.images !== false) {
|
|
709
|
+
for (const c of batch.comments) {
|
|
710
|
+
for (const file of [c.screenshot, ...c.attachments].filter(Boolean)) {
|
|
711
|
+
if (extra.length >= FEEDBACK_INLINE_IMAGES) break;
|
|
712
|
+
try {
|
|
713
|
+
const data = fs.readFileSync(file);
|
|
714
|
+
if (bytes + data.length > FEEDBACK_INLINE_BYTES) continue;
|
|
715
|
+
bytes += data.length;
|
|
716
|
+
extra.push({ type: 'text', text: `Comment ${c.n}: ${file === c.screenshot ? 'the element' : 'image the person attached'}` });
|
|
717
|
+
extra.push({ type: 'image', data: data.toString('base64'), mimeType: MIME_BY_EXT[path.extname(file).slice(1)] ?? 'image/png' });
|
|
718
|
+
} catch { /* pruned meanwhile: the path is still listed */ }
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
const pages = [...new Set(batch.comments.map((c) => c.page.url))];
|
|
723
|
+
return {
|
|
724
|
+
env: okEnvelope(traceId,
|
|
725
|
+
`${batch.comments.length} comment(s) from the person on ${pages.length === 1 ? pages[0] : `${pages.length} pages`}.`,
|
|
726
|
+
{ batch },
|
|
727
|
+
{}),
|
|
728
|
+
extra,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
555
732
|
// ─── Workflows and plan (docs/WORKFLOWS.md, docs/PRO.md) ─────────────────────
|
|
556
733
|
|
|
557
734
|
/** The active recording for this MCP session, if any. In memory only. */
|
|
@@ -752,7 +929,7 @@ async function runTool(requestId, traceId, args, progress) {
|
|
|
752
929
|
* redaction as screenshot_capture).
|
|
753
930
|
*/
|
|
754
931
|
async function runBatchOrOnce({
|
|
755
|
-
wf, params, paramSets, labels, onError, startAt = 0, approvalId, signal,
|
|
932
|
+
wf, params, paramSets, labels, onError, startAt = 0, approvalId, signal, resume = [],
|
|
756
933
|
onCall = () => {}, onStep = () => {}, onSet = () => {}, onSetDone = () => {},
|
|
757
934
|
}) {
|
|
758
935
|
const execute = async (tool, stepArgs, { allowedOrigins }) => {
|
|
@@ -769,14 +946,14 @@ async function runBatchOrOnce({
|
|
|
769
946
|
const { env } = await executeTool('screenshot_capture', {}, { allowedOrigins: ALLOWED_ORIGINS });
|
|
770
947
|
return env.ok ? env.screenshotPath ?? null : null;
|
|
771
948
|
};
|
|
772
|
-
const runOnce = async (setParams, first) => {
|
|
949
|
+
const runOnce = async (setParams, first, setStartAt = 0) => {
|
|
773
950
|
const startedAt = new Date().toISOString();
|
|
774
951
|
const history = workflows.runHistory(wf);
|
|
775
952
|
const result = await workflows.run(wf, {
|
|
776
953
|
history,
|
|
777
954
|
onStep,
|
|
778
955
|
params: setParams,
|
|
779
|
-
startAt: first ? startAt : 0,
|
|
956
|
+
startAt: setStartAt || (first ? startAt : 0),
|
|
780
957
|
approvalId: first ? approvalId : undefined,
|
|
781
958
|
execute,
|
|
782
959
|
capture,
|
|
@@ -787,7 +964,7 @@ async function runBatchOrOnce({
|
|
|
787
964
|
service.recordRun({ wf, result, params: setParams, startedAt });
|
|
788
965
|
return result;
|
|
789
966
|
};
|
|
790
|
-
if (paramSets === undefined) return runOnce(params, true);
|
|
967
|
+
if (paramSets === undefined) return runOnce(params, true, startAt);
|
|
791
968
|
let first = true;
|
|
792
969
|
return pro.runBulk({
|
|
793
970
|
wf,
|
|
@@ -795,9 +972,22 @@ async function runBatchOrOnce({
|
|
|
795
972
|
labels,
|
|
796
973
|
onError,
|
|
797
974
|
signal,
|
|
975
|
+
resume,
|
|
976
|
+
// A resume at the failed step is probed first, as a real wait_for_state
|
|
977
|
+
// through executeTool; the page has moved, so the set starts at the
|
|
978
|
+
// navigation anchor instead (A6-09). With no anchor it fails without acting.
|
|
979
|
+
resumeStart: ({ plan, params: setParams }) => probeResume({
|
|
980
|
+
wf, plan, params: setParams, execute, allowedOrigins: ALLOWED_ORIGINS,
|
|
981
|
+
}),
|
|
798
982
|
onSet,
|
|
799
983
|
onSetDone,
|
|
800
|
-
|
|
984
|
+
// A re-run of the sets that failed resumes at the step that failed where
|
|
985
|
+
// the page allows it (0.6.0), instead of starting every set at step 1.
|
|
986
|
+
runOnce: (setParams, setStartAt) => {
|
|
987
|
+
const r = runOnce(setParams, first, setStartAt ?? 0);
|
|
988
|
+
first = false;
|
|
989
|
+
return r;
|
|
990
|
+
},
|
|
801
991
|
});
|
|
802
992
|
}
|
|
803
993
|
|
|
@@ -808,6 +998,7 @@ const backgroundRuns = createRunRegistry({
|
|
|
808
998
|
paramSets: job.paramSets,
|
|
809
999
|
labels: job.labels,
|
|
810
1000
|
onError: job.onError,
|
|
1001
|
+
resume: job.resume,
|
|
811
1002
|
signal: job.controller.signal,
|
|
812
1003
|
onCall: (c) => { job.call = c; },
|
|
813
1004
|
onStep: ({ index }) => backgroundRuns.progress(job, { stepIndex: index }),
|
|
@@ -823,7 +1014,7 @@ const backgroundRuns = createRunRegistry({
|
|
|
823
1014
|
});
|
|
824
1015
|
|
|
825
1016
|
/** Tools that only read, allowed while a background run drives the tab. */
|
|
826
|
-
const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'browser_list_tabs', 'browser_list_profiles']);
|
|
1017
|
+
const READ_WHILE_RUNNING = new Set(['browser_status', 'page_inspect', 'screenshot_capture', 'page_console', 'page_network', 'browser_list_tabs', 'browser_list_profiles']);
|
|
827
1018
|
|
|
828
1019
|
/**
|
|
829
1020
|
* "5 of 5 done" / "3 of 5 done, 2 need attention" as a Chrome notification
|
|
@@ -863,14 +1054,16 @@ function statusOfRuns(traceId, args) {
|
|
|
863
1054
|
}
|
|
864
1055
|
|
|
865
1056
|
function rerunFailedTool(traceId, args) {
|
|
866
|
-
const { job: previous, paramSets, labels } = backgroundRuns.failedSets(args.rerunFailed);
|
|
1057
|
+
const { job: previous, paramSets, labels, resume } = backgroundRuns.failedSets(args.rerunFailed);
|
|
867
1058
|
if (previous.workflow !== args.name) {
|
|
868
1059
|
return errorEnvelope(traceId, 'INVALID_ARGUMENT', `${previous.runId} ran "${previous.workflow}", not "${args.name}".`);
|
|
869
1060
|
}
|
|
870
1061
|
if (paramSets.length === 0) return okEnvelope(traceId, `Every set of ${previous.runId} completed; nothing to run again.`);
|
|
871
|
-
const job = backgroundRuns.start({ wf: workflows.load(previous.workflow), paramSets, labels, onError: 'continue' });
|
|
1062
|
+
const job = backgroundRuns.start({ wf: workflows.load(previous.workflow), paramSets, labels, onError: 'continue', resume });
|
|
1063
|
+
const resumed = resume.filter((plan) => plan?.at > 0).length;
|
|
872
1064
|
return okEnvelope(traceId,
|
|
873
|
-
`Running the ${paramSets.length} set(s) of ${previous.runId} that did not complete
|
|
1065
|
+
`Running the ${paramSets.length} set(s) of ${previous.runId} that did not complete` +
|
|
1066
|
+
`${resumed ? `, ${resumed} from the step that failed` : ''}, in the background (${job.runId}).`,
|
|
874
1067
|
{ runId: job.runId }, { paramSets: paramSets.length, rerunOf: previous.runId });
|
|
875
1068
|
}
|
|
876
1069
|
|
|
@@ -882,7 +1075,7 @@ async function profileTool(traceId, toolName, args) {
|
|
|
882
1075
|
? await bridgeRequest('profiles_list')
|
|
883
1076
|
: await bridgeRequest('profile_select', { profile: args.profile });
|
|
884
1077
|
} catch (err) {
|
|
885
|
-
return errorEnvelope(traceId, err.code || 'BRIDGE_DISCONNECTED', err.message, '
|
|
1078
|
+
return errorEnvelope(traceId, err.code || 'BRIDGE_DISCONNECTED', err.message, 'Ask the user to run "browserzap doctor".');
|
|
886
1079
|
}
|
|
887
1080
|
const profiles = reply.data?.profiles ?? [];
|
|
888
1081
|
if (!reply.ok) {
|
|
@@ -931,7 +1124,7 @@ async function statusTool(traceId, args) {
|
|
|
931
1124
|
evidence.bridge = 'connected';
|
|
932
1125
|
} catch {
|
|
933
1126
|
return { ...errorEnvelope(traceId, 'BRIDGE_DISCONNECTED', describeBridgeProblem(),
|
|
934
|
-
'Ask the user to run "
|
|
1127
|
+
'Ask the user to run "browserzap doctor"; every failed line says what to do.'), evidence };
|
|
935
1128
|
}
|
|
936
1129
|
}
|
|
937
1130
|
try {
|
|
@@ -951,6 +1144,10 @@ async function statusTool(traceId, args) {
|
|
|
951
1144
|
const result = await call.promise;
|
|
952
1145
|
const env = envelope(call.id, result);
|
|
953
1146
|
env.evidence = { ...evidence, extension: 'connected' };
|
|
1147
|
+
const waiting = feedback.pendingCount();
|
|
1148
|
+
if (waiting && typeof env.summary === 'string') {
|
|
1149
|
+
env.summary = `${env.summary} The person sent ${waiting} comment batch(es) from Comment mode: call feedback_get.`;
|
|
1150
|
+
}
|
|
954
1151
|
return env;
|
|
955
1152
|
} catch {
|
|
956
1153
|
evidence.extension = 'not_connected';
|
|
@@ -1012,7 +1209,11 @@ async function handleRequest(request) {
|
|
|
1012
1209
|
'For a task the user repeats, record it once with workflow_record and replay it with workflow_run. ' +
|
|
1013
1210
|
'Tabs: work in the session\'s main tab and navigate it for each new task. Open a helper with ' +
|
|
1014
1211
|
'browser_new_tab only for a side trip (research, a comparison) and close it with browser_close_tab when done, ' +
|
|
1015
|
-
'which takes you back to the main tab. A session keeps at most 3 tabs between tasks; its tabs close when it ends.'
|
|
1212
|
+
'which takes you back to the main tab. A session keeps at most 3 tabs between tasks; its tabs close when it ends. ' +
|
|
1213
|
+
'When the user mentions their BrowserZap feedback or comments, call feedback_get: it returns the notes they ' +
|
|
1214
|
+
'made on page elements in Comment mode, with components, source files and screenshots. When debugging a page, ' +
|
|
1215
|
+
'page_console and page_network show what it logged and requested. A click that downloads a file says so; ' +
|
|
1216
|
+
'browser_downloads lists and waits for downloads. file_upload always needs the user\'s approval per file.',
|
|
1016
1217
|
});
|
|
1017
1218
|
break;
|
|
1018
1219
|
}
|
|
@@ -26,7 +26,7 @@ const { getOrCreateSecret } = require('./secret');
|
|
|
26
26
|
const { DEFAULT_PORT, PROTOCOL_VERSION, fileLogger } = require('./server');
|
|
27
27
|
const { ensureBridge } = require('./ensure-bridge');
|
|
28
28
|
const { helloProbe } = require('./probe');
|
|
29
|
-
const { HOST_NAME,
|
|
29
|
+
const { HOST_NAME, allowedOrigins } = require('./extension-id');
|
|
30
30
|
|
|
31
31
|
/** Inbound frames above this are rejected before parsing. Chrome's own host→Chrome cap is 1 MiB. */
|
|
32
32
|
const MAX_MESSAGE_BYTES = 1024 * 1024;
|
|
@@ -130,7 +130,9 @@ async function handleRequest(request, options = {}) {
|
|
|
130
130
|
});
|
|
131
131
|
if (!verified.ok) {
|
|
132
132
|
logLine('bridge_not_verified', { port, code: verified.code });
|
|
133
|
-
|
|
133
|
+
// 4003: a BrowserZap bridge is listening but holds another install's secret
|
|
134
|
+
// (D-39). The extension shows the fix instead of staying grey.
|
|
135
|
+
return { type: 'error', error: verified.code === 4003 ? 'bridge_foreign' : 'bridge_unavailable' };
|
|
134
136
|
}
|
|
135
137
|
verified.ws.close();
|
|
136
138
|
logLine('hello_served', { port });
|
|
@@ -141,7 +143,7 @@ function main() {
|
|
|
141
143
|
// Defense in depth: allowed_origins is the real control, but refuse a caller
|
|
142
144
|
// that is not the BrowserZap extension if Chrome tells us who it is.
|
|
143
145
|
const origin = process.argv[2];
|
|
144
|
-
if (typeof origin === 'string' && origin.startsWith('chrome-extension://') &&
|
|
146
|
+
if (typeof origin === 'string' && origin.startsWith('chrome-extension://') && !allowedOrigins().includes(origin)) {
|
|
145
147
|
logLine('wrong_origin');
|
|
146
148
|
process.exit(1);
|
|
147
149
|
}
|
package/bridge-server/panel.js
CHANGED
|
@@ -38,7 +38,9 @@ const { CALL_DEADLINE_MS } = require('./limits');
|
|
|
38
38
|
const { createWorkflowService, checkStep, stepLogEntry } = require('./workflow-service');
|
|
39
39
|
const { createSessionLog, describeResult } = require('./session-log');
|
|
40
40
|
const { parseParamSets } = require('./param-sets');
|
|
41
|
+
const { resumePlan, probeResume } = require('./background-runs');
|
|
41
42
|
const screenshots = require('./screenshots');
|
|
43
|
+
const feedback = require('./feedback');
|
|
42
44
|
|
|
43
45
|
const PANEL_SESSION = 'panel';
|
|
44
46
|
const PANEL_SESSION_NAME = 'BrowserZap workflows';
|
|
@@ -217,7 +219,13 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
217
219
|
if (ok) current.batch.done += 1;
|
|
218
220
|
else current.batch.failed += 1;
|
|
219
221
|
},
|
|
220
|
-
|
|
222
|
+
// "Re-run failed" probes the failed step's target before resuming there,
|
|
223
|
+
// as a real wait_for_state; a page that has moved starts at the
|
|
224
|
+
// navigation anchor, and with no anchor the set fails without acting (A6-09).
|
|
225
|
+
resumeStart: ({ plan, params: setParams }) => probeResume({
|
|
226
|
+
wf, plan, params: setParams, execute, allowedOrigins: wf.origins,
|
|
227
|
+
}),
|
|
228
|
+
runOnce: (setParams, setStartAt) => runOnce(setParams, setStartAt ?? 0),
|
|
221
229
|
});
|
|
222
230
|
const runs = result.evidence?.runs ?? [];
|
|
223
231
|
lastBatch = {
|
|
@@ -225,6 +233,9 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
225
233
|
paramSets: batch.paramSets,
|
|
226
234
|
labels: batch.labels,
|
|
227
235
|
unfinished: batch.paramSets.map((_, i) => i).filter((i) => !runs.some((r) => r.index === i && r.ok)),
|
|
236
|
+
// Where each set may resume when the person presses "Re-run failed" (0.6.0):
|
|
237
|
+
// the plan a probe proves before it is used (A6-09).
|
|
238
|
+
resumeByIndex: Object.fromEntries(runs.filter((r) => !r.ok).map((r) => [r.index, resumePlan(wf, r)])),
|
|
228
239
|
};
|
|
229
240
|
// What the person needs to find each problem: step, cause, screenshot. Never values.
|
|
230
241
|
const attention = runs.filter((r) => !r.ok).map((r) => ({
|
|
@@ -240,7 +251,7 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
240
251
|
status: result.ok ? 'completed' : controller.signal.aborted ? 'cancelled' : 'failed',
|
|
241
252
|
summary: result.summary,
|
|
242
253
|
code: result.error?.code ?? null,
|
|
243
|
-
nextAction: lastBatch.unfinished.length ? `${lastBatch.unfinished.length} set(s) did not complete. "Re-run failed" runs just those, from the
|
|
254
|
+
nextAction: lastBatch.unfinished.length ? `${lastBatch.unfinished.length} set(s) did not complete. "Re-run failed" runs just those, from the step that failed where the page allows it.` : null,
|
|
244
255
|
canContinueFrom: null,
|
|
245
256
|
batch: { ...current.batch, attention, report: result.evidence?.report ?? null, canRerun: lastBatch.unfinished.length > 0 },
|
|
246
257
|
finishedAt: new Date().toISOString(),
|
|
@@ -395,7 +406,8 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
395
406
|
const wf = workflows.load(lastBatch.workflow);
|
|
396
407
|
const paramSets = lastBatch.unfinished.map((i) => lastBatch.paramSets[i]);
|
|
397
408
|
const labels = lastBatch.unfinished.map((i) => lastBatch.labels[i] ?? `#${i + 1}`);
|
|
398
|
-
|
|
409
|
+
const resume = lastBatch.unfinished.map((i) => lastBatch.resumeByIndex?.[i] ?? null);
|
|
410
|
+
runWorkflow(wf, {}, 0, profile, { paramSets, labels, resume }).catch((err) => log('panel_run_error', { message: err.message }));
|
|
399
411
|
return { ok: true, data: { runId: current.runId, sets: paramSets.length } };
|
|
400
412
|
}
|
|
401
413
|
case 'run':
|
|
@@ -424,6 +436,12 @@ function createPanelRunner({ deliver, toExtension, log = () => {} }) {
|
|
|
424
436
|
case 'delete':
|
|
425
437
|
workflows.remove(String(args.name));
|
|
426
438
|
return { ok: true, data: {} };
|
|
439
|
+
case 'feedback_submit': {
|
|
440
|
+
// Comment mode (docs/COMMENT_MODE.md): stored on this device for the agent's feedback_get.
|
|
441
|
+
const saved = feedback.saveBatch(args.batch);
|
|
442
|
+
log('feedback_saved', { batch: saved.id, comments: saved.count });
|
|
443
|
+
return { ok: true, data: { batchId: saved.id, count: saved.count } };
|
|
444
|
+
}
|
|
427
445
|
default:
|
|
428
446
|
return { ok: false, error: { code: 'INVALID_ARGUMENT', message: `Unknown panel request "${op}".` } };
|
|
429
447
|
}
|
package/bridge-server/probe.js
CHANGED
|
@@ -101,7 +101,8 @@ function nodePidsOnPort(port) {
|
|
|
101
101
|
return [...pids].filter((pid) => /node\.exe/i.test(execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true })));
|
|
102
102
|
}
|
|
103
103
|
for (const pid of execFileSync('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' }).split(/\s+/).filter(Boolean)) {
|
|
104
|
-
|
|
104
|
+
// `comm` can be Node's thread name ("MainThread") on Linux; the command line names node.
|
|
105
|
+
if (/(^|\/)node(\.exe)?(\s|$)/i.test(execFileSync('ps', ['-p', pid, '-o', 'args='], { encoding: 'utf8' }).trim())) pids.add(pid);
|
|
105
106
|
}
|
|
106
107
|
} catch { /* netstat/lsof missing or nothing found */ }
|
|
107
108
|
return [...pids];
|