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.
@@ -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 { createRunRegistry, compactBatch, RunError } = require('./background-runs');
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 "npm run health" for details.';
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 "npm run health" and check that the extension is loaded in Chrome.';
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 "npm run bridge:restart".'), { code: 'BRIDGE_DISCONNECTED' }));
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
- const { env, extra } = await executeTool(toolName, args, { onStart: (c) => { call.cancel = c.cancel; } });
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
- runOnce: (setParams) => { const r = runOnce(setParams, first); first = false; return r; },
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, from the start, in the background (${job.runId}).`,
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, 'Run "npm run health".');
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 "npm run health" in the BrowserZap folder.'), evidence };
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, extensionOrigin } = require('./extension-id');
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
- return { type: 'error', error: 'bridge_unavailable' };
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://') && origin !== extensionOrigin()) {
146
+ if (typeof origin === 'string' && origin.startsWith('chrome-extension://') && !allowedOrigins().includes(origin)) {
145
147
  logLine('wrong_origin');
146
148
  process.exit(1);
147
149
  }
@@ -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
- runOnce: (setParams) => runOnce(setParams, 0),
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 start.` : null,
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
- runWorkflow(wf, {}, 0, profile, { paramSets, labels }).catch((err) => log('panel_run_error', { message: err.message }));
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
  }
@@ -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
- if (/node/i.test(execFileSync('ps', ['-p', pid, '-o', 'comm='], { encoding: 'utf8' }))) pids.add(pid);
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];