@harness-fe/runtime 3.4.1 → 4.0.0-next.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/dist/capture.d.ts CHANGED
@@ -65,7 +65,7 @@ export declare class CaptureStore {
65
65
  }>;
66
66
  readonly storage: RingBuffer<{
67
67
  op: "set" | "remove" | "clear";
68
- which: "local" | "session" | "cookie";
68
+ which: "session" | "local" | "cookie";
69
69
  ts: number;
70
70
  key?: string | undefined;
71
71
  value?: string | undefined;
package/dist/client.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Started lazily by `auto-start.ts` when the script is imported.
6
6
  */
7
- import { COMMAND } from '@harness-fe/protocol';
7
+ import { COMMAND, type ConsentDecision, type ConsentRequest } from '@harness-fe/protocol';
8
8
  import type { QueryMethod } from '@harness-fe/protocol';
9
9
  export interface ClientOptions {
10
10
  projectId: string;
@@ -54,6 +54,11 @@ export declare class RuntimeClient {
54
54
  /** WebSocket state: 'connecting' | 'open' | 'closed'. */
55
55
  getConnectionState(): 'connecting' | 'open' | 'closed';
56
56
  private pageLoadSent;
57
+ private consentMode;
58
+ /** Set once the user grants blanket control for this pageload (mode=session). */
59
+ private consentSessionGranted;
60
+ /** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
61
+ private consentPrompter?;
57
62
  private readonly ctx;
58
63
  private readonly recorder;
59
64
  private reconnectAttempts;
@@ -69,8 +74,20 @@ export declare class RuntimeClient {
69
74
  private onClose;
70
75
  private onMessage;
71
76
  private onQueryResponse;
77
+ /**
78
+ * Register the consent prompter (the overlay installs this). When the
79
+ * policy is on and a control command arrives, the client asks this for the
80
+ * user's decision. Without it, gated commands are denied (fail-safe).
81
+ */
82
+ setConsentPrompter(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
72
83
  private onHelloAck;
73
84
  private handleCommand;
85
+ /**
86
+ * Ask the user (via the overlay-registered prompter) to approve a control
87
+ * command. Fail-safe: if no prompter is registered, or it throws, deny —
88
+ * a consent policy that can't ask must not silently allow.
89
+ */
90
+ private requestConsent;
74
91
  sendEvent(name: string, payload: unknown): void;
75
92
  /**
76
93
  * Request/reply RPC to the daemon. Currently used by the in-page
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Started lazily by `auto-start.ts` when the script is imported.
6
6
  */
7
- import { COMMAND, DEFAULT_WS_PORT, EVENT_NAME, frameSchema, } from '@harness-fe/protocol';
7
+ import { ALWAYS_CONFIRM_COMMANDS, COMMAND, DEFAULT_WS_PORT, EVENT_NAME, requiresConsent, frameSchema, } from '@harness-fe/protocol';
8
8
  import { getCaptureStore } from './capture.js';
9
9
  import { commandHandlers } from './commands.js';
10
10
  import { Outbox } from './outbox.js';
@@ -89,6 +89,14 @@ export class RuntimeClient {
89
89
  }
90
90
  }
91
91
  pageLoadSent = false;
92
+ // Browser consent (4.0 · P2). Mode comes from the daemon in hello.ack;
93
+ // default `off` so a daemon that never sends a policy (or loopback solo
94
+ // dev) keeps running control commands without prompting.
95
+ consentMode = 'off';
96
+ /** Set once the user grants blanket control for this pageload (mode=session). */
97
+ consentSessionGranted = false;
98
+ /** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
99
+ consentPrompter;
92
100
  ctx = { capture: getCaptureStore() };
93
101
  // Initialized in constructor (parameter property `opts` isn't readable at
94
102
  // class-field-initializer time — field initializers run before parameter
@@ -209,11 +217,21 @@ export class RuntimeClient {
209
217
  pending.reject(new Error(frame.error?.message ?? 'query failed'));
210
218
  }
211
219
  }
220
+ /**
221
+ * Register the consent prompter (the overlay installs this). When the
222
+ * policy is on and a control command arrives, the client asks this for the
223
+ * user's decision. Without it, gated commands are denied (fail-safe).
224
+ */
225
+ setConsentPrompter(fn) {
226
+ this.consentPrompter = fn;
227
+ }
212
228
  onHelloAck(frame) {
213
229
  if (frame.error) {
214
230
  // Bridge rejected this hello — do not send PAGE_LOAD.
215
231
  return;
216
232
  }
233
+ // Adopt the daemon's consent policy for this connection (4.0 · P2).
234
+ this.consentMode = frame.consent?.mode ?? 'off';
217
235
  // Force a fresh rrweb FullSnapshot on every ack — including reconnects
218
236
  // after daemon restart, network blips, or page-recovery from sleep.
219
237
  // Without this, the only baseline for the session is whatever rrweb
@@ -247,6 +265,23 @@ export class RuntimeClient {
247
265
  });
248
266
  return;
249
267
  }
268
+ // Browser-consent gate (4.0 · P2): control commands need the user's
269
+ // OK in the page before they run when the daemon enabled consent.
270
+ if (requiresConsent(frame.command, this.consentMode, this.consentSessionGranted)) {
271
+ const decision = await this.requestConsent(frame);
272
+ if (decision === 'deny') {
273
+ this.send({
274
+ type: 'response',
275
+ id: frame.id,
276
+ ok: false,
277
+ error: { code: 'CONSENT_DENIED', message: `user denied "${frame.command}"` },
278
+ });
279
+ return;
280
+ }
281
+ if (decision === 'session')
282
+ this.consentSessionGranted = true;
283
+ // 'once' → run this one without granting the rest of the session.
284
+ }
250
285
  try {
251
286
  const result = await handler(frame.args ?? {}, this.ctx);
252
287
  this.send({
@@ -266,6 +301,27 @@ export class RuntimeClient {
266
301
  });
267
302
  }
268
303
  }
304
+ /**
305
+ * Ask the user (via the overlay-registered prompter) to approve a control
306
+ * command. Fail-safe: if no prompter is registered, or it throws, deny —
307
+ * a consent policy that can't ask must not silently allow.
308
+ */
309
+ async requestConsent(frame) {
310
+ if (!this.consentPrompter)
311
+ return 'deny';
312
+ const req = {
313
+ command: frame.command,
314
+ args: frame.args,
315
+ tabId: this.tabId,
316
+ alwaysConfirm: ALWAYS_CONFIRM_COMMANDS.has(frame.command),
317
+ };
318
+ try {
319
+ return await this.consentPrompter(req);
320
+ }
321
+ catch {
322
+ return 'deny';
323
+ }
324
+ }
269
325
  sendEvent(name, payload) {
270
326
  const event = {
271
327
  type: 'event',
package/dist/overlay.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * Single Shadow DOM root attached to <body>; host page styles never leak in
14
14
  * or out. State machine: idle → info → (picker → question) → flash → idle.
15
15
  */
16
- import { type TaskAttachment } from '@harness-fe/protocol';
16
+ import { type ConsentDecision, type ConsentRequest, type TaskAttachment } from '@harness-fe/protocol';
17
17
  import { type OverlayPluginLogs, type OverlayPluginGetLogsOptions } from './pluginRegistry.js';
18
18
  export interface OverlayClient {
19
19
  readonly projectId: string;
@@ -37,6 +37,12 @@ export interface OverlayClient {
37
37
  * tasks. Resolves with `result`, rejects with the remote error message.
38
38
  */
39
39
  query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
40
+ /**
41
+ * Register the browser-consent prompter (4.0 · P2). The client calls this
42
+ * to ask the user before running a control command. Optional so embedders
43
+ * with a non-RuntimeClient overlay client still type-check.
44
+ */
45
+ setConsentPrompter?(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
40
46
  }
41
47
  export declare function installOverlay(client: OverlayClient): void;
42
48
  interface ArrowStroke {
package/dist/overlay.js CHANGED
@@ -77,8 +77,9 @@ export function installOverlay(client) {
77
77
  const pickerBar = buildPickerBar();
78
78
  const annotateModal = buildAnnotateModal();
79
79
  const questionPanel = buildQuestionPanel();
80
+ const consentPanel = buildConsentPanel();
80
81
  const highlight = buildHighlight();
81
- root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
82
+ root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
82
83
  const mount = () => {
83
84
  if (!document.body)
84
85
  return false;
@@ -249,12 +250,6 @@ export function installOverlay(client) {
249
250
  let pendingAttachment = null;
250
251
  /** Set while the picker is collecting an element for a `requiresElement` plugin. */
251
252
  let pluginAwaitingElement = null;
252
- /**
253
- * Purpose of the current picker session:
254
- * - 'copy': copy element info to clipboard for use with an agent (default)
255
- * - 'report': legacy report-a-problem flow (still used internally by plugins)
256
- */
257
- let pickerPurpose = 'copy';
258
253
  const setState = (next) => {
259
254
  state = next;
260
255
  infoCard.style.display = next === 'info' ? 'flex' : 'none';
@@ -262,6 +257,7 @@ export function installOverlay(client) {
262
257
  pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
263
258
  annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
264
259
  questionPanel.style.display = next === 'question' ? 'flex' : 'none';
260
+ consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
265
261
  document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
266
262
  fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
267
263
  if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
@@ -290,6 +286,40 @@ export function installOverlay(client) {
290
286
  requestAnimationFrame(() => repositionCards());
291
287
  }
292
288
  };
289
+ // ─── Browser consent prompter (4.0 · P2) ─────────────────────────────
290
+ // The client calls this before running a control command when the daemon
291
+ // enabled consent. We show a modal and resolve with the user's choice.
292
+ const consentCmdEl = consentPanel.querySelector('[data-role="consent-cmd"]');
293
+ const consentAllowOnce = consentPanel.querySelector('[data-role="consent-once"]');
294
+ const consentAllowSession = consentPanel.querySelector('[data-role="consent-session"]');
295
+ const consentDeny = consentPanel.querySelector('[data-role="consent-deny"]');
296
+ let consentChain = Promise.resolve();
297
+ const promptConsent = (req) => new Promise((resolve) => {
298
+ consentCmdEl.textContent = formatConsentCommand(req);
299
+ // page.evaluate (alwaysConfirm) can never be granted session-wide.
300
+ consentAllowSession.style.display = req.alwaysConfirm ? 'none' : '';
301
+ setState('consent');
302
+ const finish = (decision) => {
303
+ consentAllowOnce.removeEventListener('click', onOnce);
304
+ consentAllowSession.removeEventListener('click', onSession);
305
+ consentDeny.removeEventListener('click', onDeny);
306
+ setState('idle');
307
+ resolve(decision);
308
+ };
309
+ const onOnce = () => finish('once');
310
+ const onSession = () => finish('session');
311
+ const onDeny = () => finish('deny');
312
+ consentAllowOnce.addEventListener('click', onOnce);
313
+ consentAllowSession.addEventListener('click', onSession);
314
+ consentDeny.addEventListener('click', onDeny);
315
+ });
316
+ // Serialize prompts so a burst of control commands queues one dialog at a time.
317
+ const showConsentPrompt = (req) => {
318
+ const result = consentChain.then(() => promptConsent(req));
319
+ consentChain = result.catch(() => undefined);
320
+ return result;
321
+ };
322
+ client.setConsentPrompter?.(showConsentPrompt);
293
323
  // ─── Picker handlers ─────────────────────────────────────────────────
294
324
  const setHighlight = (el) => {
295
325
  if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
@@ -328,7 +358,7 @@ export function installOverlay(client) {
328
358
  lockedEl = hoveredEl;
329
359
  setHighlight(lockedEl);
330
360
  // A plugin requested the element — hand it straight to its onClick and
331
- // skip all other flows.
361
+ // skip the report/question flow entirely.
332
362
  if (pluginAwaitingElement) {
333
363
  const plugin = pluginAwaitingElement;
334
364
  pluginAwaitingElement = null;
@@ -338,18 +368,9 @@ export function installOverlay(client) {
338
368
  void invokePlugin(plugin, el);
339
369
  return;
340
370
  }
341
- // Copy mode: build element info and copy to clipboard for agent use.
342
- if (pickerPurpose === 'copy') {
343
- const el = lockedEl;
344
- lockedEl = null;
345
- const text = buildElementCopyText(el);
346
- void copyText(text).then(() => {
347
- showToast('✓ Element info copied');
348
- });
349
- setState('idle');
350
- return;
351
- }
352
- // Report mode (legacy, no longer exposed in UI but kept for plugin compatibility).
371
+ // Go straight to the question step. Screenshots are now opt-in via
372
+ // the "Add screenshot" button inside the question panel — users
373
+ // shouldn't have to draw on every report.
353
374
  pendingAttachment = null;
354
375
  const info = questionPanel.querySelector('[data-role=info]');
355
376
  info.textContent = describeElement(lockedEl);
@@ -373,7 +394,6 @@ export function installOverlay(client) {
373
394
  lockedEl = null;
374
395
  pendingAttachment = null;
375
396
  pluginAwaitingElement = null;
376
- pickerPurpose = 'copy';
377
397
  setState('info');
378
398
  }
379
399
  else if (state === 'info') {
@@ -444,31 +464,6 @@ export function installOverlay(client) {
444
464
  }, 1200);
445
465
  }
446
466
  };
447
- /**
448
- * Build a compact element-info block for pasting into an agent prompt.
449
- * Omits HTML (too verbose); includes source location, component name, css
450
- * path, and session context — enough for the agent to locate and fix the
451
- * element without any further investigation.
452
- */
453
- const buildElementCopyText = (el) => {
454
- const tag = el.tagName.toLowerCase();
455
- const comp = el.getAttribute('data-morphix-comp');
456
- const loc = el.getAttribute('data-morphix-loc');
457
- const css = buildCssPath(el);
458
- const lines = [];
459
- lines.push(`### Element context`);
460
- lines.push('');
461
- if (comp)
462
- lines.push(`- component: \`${comp}\``);
463
- if (loc)
464
- lines.push(`- source: \`${loc}\``);
465
- lines.push(`- tag: \`${tag}\``);
466
- lines.push(`- css: \`${css}\``);
467
- lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
468
- lines.push(`- session: \`${client.sessionId}\``);
469
- lines.push(`- url: ${location.href}`);
470
- return lines.join('\n') + '\n';
471
- };
472
467
  const buildSnapshot = () => {
473
468
  const lines = [];
474
469
  lines.push(`### Harness-FE snapshot`);
@@ -566,10 +561,6 @@ export function installOverlay(client) {
566
561
  btn.addEventListener('click', () => {
567
562
  if (plugin.requiresElement) {
568
563
  pluginAwaitingElement = plugin;
569
- pickerPurpose = 'report'; // plugins use the legacy element-selection flow
570
- const label = pickerBar.querySelector('[data-role=picker-label]');
571
- if (label)
572
- label.textContent = '🎯 Click an element';
573
564
  setState('picker');
574
565
  }
575
566
  else {
@@ -798,17 +789,16 @@ export function installOverlay(client) {
798
789
  setState(state === 'idle' ? 'info' : 'idle');
799
790
  });
800
791
  infoCard.querySelector('[data-role=close]').addEventListener('click', () => setState('idle'));
801
- infoCard.querySelector('[data-role=pick-element]').addEventListener('click', () => {
802
- pickerPurpose = 'copy';
803
- const label = pickerBar.querySelector('[data-role=picker-label]');
804
- if (label)
805
- label.textContent = '🔍 Click element to copy info';
792
+ infoCard.querySelector('[data-role=report]').addEventListener('click', () => {
806
793
  setState('picker');
807
794
  });
808
795
  infoCard.querySelector('[data-role=copy-snapshot]').addEventListener('click', (ev) => {
809
796
  const btn = ev.currentTarget;
810
797
  void copyText(buildSnapshot(), btn);
811
798
  });
799
+ infoCard.querySelector('[data-role=view-reports]').addEventListener('click', () => {
800
+ setState('reports');
801
+ });
812
802
  // "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
813
803
  // pop it in a new tab, deep-linked to this session. Show the button
814
804
  // only when we actually know the daemon address (mcpUrl was supplied by
@@ -861,7 +851,6 @@ export function installOverlay(client) {
861
851
  pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
862
852
  lockedEl = null;
863
853
  pluginAwaitingElement = null;
864
- pickerPurpose = 'copy';
865
854
  setState('info');
866
855
  });
867
856
  questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
@@ -1986,15 +1975,16 @@ function buildInfoCard() {
1986
1975
  <div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
1987
1976
  </div>
1988
1977
  <div class="actions">
1989
- <button class="primary" data-role="pick-element" type="button">
1990
- <span class="icon">🔍</span>
1991
- <span class="label">Copy element info</span>
1992
- <span class="hint">pick element →</span>
1978
+ <button class="primary" data-role="report" type="button">
1979
+ <span class="icon">🎯</span>
1980
+ <span class="label">Report a problem</span>
1981
+ <span class="hint">Pick an element →</span>
1993
1982
  </button>
1994
1983
  <button class="secondary" data-role="open-dashboard" type="button" style="display:none">
1995
1984
  <span class="icon">↗</span>
1996
1985
  <span>Open dashboard</span>
1997
1986
  </button>
1987
+ <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
1998
1988
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
1999
1989
  </div>
2000
1990
  <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
@@ -2025,7 +2015,7 @@ function buildPickerBar() {
2025
2015
  const bar = document.createElement('div');
2026
2016
  bar.className = 'picker-bar';
2027
2017
  bar.innerHTML = `
2028
- <span class="label" data-role="picker-label">🔍 Click element to copy info</span>
2018
+ <span class="label">🎯 Click an element to flag it</span>
2029
2019
  <span class="hint">esc to cancel</span>
2030
2020
  <button data-role="cancel" type="button">Cancel</button>
2031
2021
  `;
@@ -2090,6 +2080,43 @@ function buildHighlight() {
2090
2080
  div.className = 'highlight';
2091
2081
  return div;
2092
2082
  }
2083
+ /**
2084
+ * Browser-consent modal (4.0 · P2). Self-contained inline styles (no reliance
2085
+ * on buildStyle) — a fixed, centered card with command preview + 3 choices.
2086
+ */
2087
+ function buildConsentPanel() {
2088
+ const panel = document.createElement('div');
2089
+ panel.className = 'consent';
2090
+ panel.style.cssText = [
2091
+ 'display:none', 'position:fixed', 'left:50%', 'top:24px', 'transform:translateX(-50%)',
2092
+ 'z-index:2147483647', 'flex-direction:column', 'gap:10px', 'max-width:380px',
2093
+ 'width:calc(100% - 32px)', 'box-sizing:border-box', 'padding:16px',
2094
+ 'background:#fff', 'color:#111', 'border:1px solid #e5e7eb', 'border-radius:10px',
2095
+ 'box-shadow:0 8px 28px rgba(0,0,0,.16)',
2096
+ 'font:13px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif',
2097
+ ].join(';');
2098
+ panel.innerHTML = `
2099
+ <div style="font-weight:600">Agent wants to control this page</div>
2100
+ <code data-role="consent-cmd" style="display:block;padding:8px 10px;background:#f6f6f7;border-radius:6px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all"></code>
2101
+ <div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
2102
+ <button data-role="consent-deny" type="button" style="padding:7px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#111;cursor:pointer">Deny</button>
2103
+ <button data-role="consent-session" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#f0f0f0;color:#111;cursor:pointer">Allow for session</button>
2104
+ <button data-role="consent-once" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer">Allow once</button>
2105
+ </div>
2106
+ `;
2107
+ return panel;
2108
+ }
2109
+ /** Render a control command + its most telling arg for the consent prompt. */
2110
+ function formatConsentCommand(req) {
2111
+ const args = req.args && typeof req.args === 'object'
2112
+ ? req.args
2113
+ : undefined;
2114
+ const detail = args?.selector ?? args?.url ?? args?.expr ?? args?.value ?? args?.predicate;
2115
+ if (detail === undefined)
2116
+ return req.command;
2117
+ const s = String(detail);
2118
+ return `${req.command}(${s.length > 80 ? `${s.slice(0, 80)}…` : s})`;
2119
+ }
2093
2120
  // ─── Element / payload helpers (unchanged from annotation.ts) ────────────
2094
2121
  function describeElement(el) {
2095
2122
  const tag = el.tagName.toLowerCase();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-fe/runtime",
3
- "version": "3.4.1",
3
+ "version": "4.0.0-next.0",
4
4
  "description": "Browser-side SDK injected into the dev page. Connects to the MCP server via WebSocket and executes commands.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@zumer/snapdom": "^2.12.0",
32
32
  "rrweb": "2.0.0-alpha.4",
33
- "@harness-fe/protocol": "3.2.0",
33
+ "@harness-fe/protocol": "4.0.0-next.0",
34
34
  "@harness-fe/sandbox": "^3.2.0"
35
35
  },
36
36
  "devDependencies": {
package/src/client.ts CHANGED
@@ -6,10 +6,15 @@
6
6
  */
7
7
 
8
8
  import {
9
+ ALWAYS_CONFIRM_COMMANDS,
9
10
  COMMAND,
10
11
  DEFAULT_WS_PORT,
11
12
  EVENT_NAME,
13
+ requiresConsent,
12
14
  type CommandFrame,
15
+ type ConsentDecision,
16
+ type ConsentMode,
17
+ type ConsentRequest,
13
18
  type EventFrame,
14
19
  type Frame,
15
20
  type HelloAckFrame,
@@ -143,6 +148,14 @@ export class RuntimeClient {
143
148
  }
144
149
  }
145
150
  private pageLoadSent = false;
151
+ // Browser consent (4.0 · P2). Mode comes from the daemon in hello.ack;
152
+ // default `off` so a daemon that never sends a policy (or loopback solo
153
+ // dev) keeps running control commands without prompting.
154
+ private consentMode: ConsentMode = 'off';
155
+ /** Set once the user grants blanket control for this pageload (mode=session). */
156
+ private consentSessionGranted = false;
157
+ /** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
158
+ private consentPrompter?: (req: ConsentRequest) => Promise<ConsentDecision>;
146
159
  private readonly ctx: CommandContext = { capture: getCaptureStore() };
147
160
  // Initialized in constructor (parameter property `opts` isn't readable at
148
161
  // class-field-initializer time — field initializers run before parameter
@@ -271,11 +284,22 @@ export class RuntimeClient {
271
284
  }
272
285
  }
273
286
 
287
+ /**
288
+ * Register the consent prompter (the overlay installs this). When the
289
+ * policy is on and a control command arrives, the client asks this for the
290
+ * user's decision. Without it, gated commands are denied (fail-safe).
291
+ */
292
+ setConsentPrompter(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void {
293
+ this.consentPrompter = fn;
294
+ }
295
+
274
296
  private onHelloAck(frame: HelloAckFrame): void {
275
297
  if (frame.error) {
276
298
  // Bridge rejected this hello — do not send PAGE_LOAD.
277
299
  return;
278
300
  }
301
+ // Adopt the daemon's consent policy for this connection (4.0 · P2).
302
+ this.consentMode = frame.consent?.mode ?? 'off';
279
303
  // Force a fresh rrweb FullSnapshot on every ack — including reconnects
280
304
  // after daemon restart, network blips, or page-recovery from sleep.
281
305
  // Without this, the only baseline for the session is whatever rrweb
@@ -309,6 +333,22 @@ export class RuntimeClient {
309
333
  } satisfies ResponseFrame);
310
334
  return;
311
335
  }
336
+ // Browser-consent gate (4.0 · P2): control commands need the user's
337
+ // OK in the page before they run when the daemon enabled consent.
338
+ if (requiresConsent(frame.command, this.consentMode, this.consentSessionGranted)) {
339
+ const decision = await this.requestConsent(frame);
340
+ if (decision === 'deny') {
341
+ this.send({
342
+ type: 'response',
343
+ id: frame.id,
344
+ ok: false,
345
+ error: { code: 'CONSENT_DENIED', message: `user denied "${frame.command}"` },
346
+ } satisfies ResponseFrame);
347
+ return;
348
+ }
349
+ if (decision === 'session') this.consentSessionGranted = true;
350
+ // 'once' → run this one without granting the rest of the session.
351
+ }
312
352
  try {
313
353
  const result = await handler(frame.args ?? {}, this.ctx);
314
354
  this.send({
@@ -328,6 +368,26 @@ export class RuntimeClient {
328
368
  }
329
369
  }
330
370
 
371
+ /**
372
+ * Ask the user (via the overlay-registered prompter) to approve a control
373
+ * command. Fail-safe: if no prompter is registered, or it throws, deny —
374
+ * a consent policy that can't ask must not silently allow.
375
+ */
376
+ private async requestConsent(frame: CommandFrame): Promise<ConsentDecision> {
377
+ if (!this.consentPrompter) return 'deny';
378
+ const req: ConsentRequest = {
379
+ command: frame.command,
380
+ args: frame.args,
381
+ tabId: this.tabId,
382
+ alwaysConfirm: ALWAYS_CONFIRM_COMMANDS.has(frame.command),
383
+ };
384
+ try {
385
+ return await this.consentPrompter(req);
386
+ } catch {
387
+ return 'deny';
388
+ }
389
+ }
390
+
331
391
  sendEvent(name: string, payload: unknown): void {
332
392
  const event: EventFrame = {
333
393
  type: 'event',
@@ -0,0 +1,115 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Browser-consent gate (4.0 · P2) in RuntimeClient.handleCommand. We drive
4
+ * handleCommand directly with a stubbed `send` and a mocked command-handler
5
+ * table, so the test isolates the consent decision wiring from real DOM ops.
6
+ */
7
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ // rrweb has CJS/ESM interop issues under happy-dom; stub it (same as the e2e).
10
+ vi.mock('rrweb', () => ({ record: () => () => {}, EventType: { Custom: 5 } }));
11
+
12
+ const handlerCalls: string[] = [];
13
+ vi.mock('./commands.js', () => ({
14
+ commandHandlers: new Proxy(
15
+ {},
16
+ {
17
+ get: (_t, prop) =>
18
+ typeof prop === 'string'
19
+ ? async () => {
20
+ handlerCalls.push(prop);
21
+ return { ran: prop };
22
+ }
23
+ : undefined,
24
+ },
25
+ ),
26
+ }));
27
+
28
+ import { COMMAND, type ConsentDecision, type ConsentMode } from '@harness-fe/protocol';
29
+ import { RuntimeClient } from './client.js';
30
+ import { getCaptureStore } from './capture.js';
31
+
32
+ type Sent = { type: string; ok?: boolean; error?: { code?: string } };
33
+
34
+ function makeClient(mode: ConsentMode) {
35
+ const c = new RuntimeClient({ projectId: 'p' });
36
+ (c as unknown as { consentMode: ConsentMode }).consentMode = mode;
37
+ const sent: Sent[] = [];
38
+ (c as unknown as { send: (f: Sent) => void }).send = (f) => sent.push(f);
39
+ return { c, sent };
40
+ }
41
+
42
+ const command = (cmd: string, id = 'id-1') => ({ type: 'command' as const, id, command: cmd, args: { selector: '.x' } });
43
+ const run = (c: RuntimeClient, frame: ReturnType<typeof command>) =>
44
+ (c as unknown as { handleCommand: (f: unknown) => Promise<void> }).handleCommand(frame);
45
+
46
+ beforeEach(() => {
47
+ handlerCalls.length = 0;
48
+ getCaptureStore().dispose();
49
+ });
50
+
51
+ describe('consent gate', () => {
52
+ it('off mode: control command runs without prompting', async () => {
53
+ const { c, sent } = makeClient('off');
54
+ const prompter = vi.fn();
55
+ c.setConsentPrompter(prompter);
56
+ await run(c, command(COMMAND.PAGE_CLICK));
57
+ expect(prompter).not.toHaveBeenCalled();
58
+ expect(handlerCalls).toEqual([COMMAND.PAGE_CLICK]);
59
+ expect(sent[0].ok).toBe(true);
60
+ });
61
+
62
+ it('deny → CONSENT_DENIED, handler never runs', async () => {
63
+ const { c, sent } = makeClient('session');
64
+ c.setConsentPrompter(async () => 'deny' as ConsentDecision);
65
+ await run(c, command(COMMAND.PAGE_CLICK));
66
+ expect(handlerCalls).toEqual([]);
67
+ expect(sent[0].ok).toBe(false);
68
+ expect(sent[0].error?.code).toBe('CONSENT_DENIED');
69
+ });
70
+
71
+ it('fail-safe: consent on but no prompter registered → deny', async () => {
72
+ const { c, sent } = makeClient('session');
73
+ await run(c, command(COMMAND.PAGE_CLICK));
74
+ expect(handlerCalls).toEqual([]);
75
+ expect(sent[0].error?.code).toBe('CONSENT_DENIED');
76
+ });
77
+
78
+ it('once: runs this command but does not grant the session', async () => {
79
+ const { c } = makeClient('session');
80
+ const prompter = vi.fn(async () => 'once' as ConsentDecision);
81
+ c.setConsentPrompter(prompter);
82
+ await run(c, command(COMMAND.PAGE_CLICK, 'a'));
83
+ await run(c, command(COMMAND.PAGE_CLICK, 'b'));
84
+ expect(prompter).toHaveBeenCalledTimes(2); // prompted each time
85
+ expect(handlerCalls).toEqual([COMMAND.PAGE_CLICK, COMMAND.PAGE_CLICK]);
86
+ });
87
+
88
+ it('session: first prompt grants blanket control for the rest of the session', async () => {
89
+ const { c } = makeClient('session');
90
+ const prompter = vi.fn(async () => 'session' as ConsentDecision);
91
+ c.setConsentPrompter(prompter);
92
+ await run(c, command(COMMAND.PAGE_CLICK, 'a'));
93
+ await run(c, command(COMMAND.PAGE_TYPE, 'b'));
94
+ expect(prompter).toHaveBeenCalledTimes(1); // second command not prompted
95
+ expect(handlerCalls).toEqual([COMMAND.PAGE_CLICK, COMMAND.PAGE_TYPE]);
96
+ });
97
+
98
+ it('page.evaluate always prompts even after a session grant', async () => {
99
+ const { c } = makeClient('session');
100
+ const prompter = vi.fn(async () => 'session' as ConsentDecision);
101
+ c.setConsentPrompter(prompter);
102
+ await run(c, command(COMMAND.PAGE_CLICK, 'a')); // grants session
103
+ await run(c, command(COMMAND.PAGE_EVALUATE, 'b')); // still prompts
104
+ expect(prompter).toHaveBeenCalledTimes(2);
105
+ });
106
+
107
+ it('read-only command is never gated', async () => {
108
+ const { c } = makeClient('session');
109
+ const prompter = vi.fn();
110
+ c.setConsentPrompter(prompter);
111
+ await run(c, command(COMMAND.CONSOLE_TAIL));
112
+ expect(prompter).not.toHaveBeenCalled();
113
+ expect(handlerCalls).toEqual([COMMAND.CONSOLE_TAIL]);
114
+ });
115
+ });
@@ -73,20 +73,20 @@ describe('installOverlay', () => {
73
73
  expect(root.querySelector('[data-role=build]')!.textContent).toBe('—');
74
74
  });
75
75
 
76
- it('"Copy element info" enters picker mode (FAB turns active, info card hidden)', () => {
76
+ it('"Report a problem" enters picker mode (FAB turns active, info card hidden)', () => {
77
77
  setupDom();
78
78
  const client = makeFakeClient();
79
79
  installOverlay(client);
80
80
  const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
81
81
  (root.querySelector('.fab') as HTMLButtonElement).click();
82
- (root.querySelector('[data-role=pick-element]') as HTMLButtonElement).click();
82
+ (root.querySelector('[data-role=report]') as HTMLButtonElement).click();
83
83
  const fab = root.querySelector('.fab') as HTMLButtonElement;
84
84
  expect(fab.dataset.state).toBe('active');
85
85
  expect((root.querySelector('.info-card') as HTMLElement).style.display).toBe('none');
86
86
  expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
87
87
  });
88
88
 
89
- it('"Copy element info" returns to idle after element click (no task.submit fired)', () => {
89
+ it('submits a task.submit event payload with selector + element on Submit', () => {
90
90
  const { doc } = setupDom();
91
91
  const target = doc.createElement('button');
92
92
  target.setAttribute('data-morphix-loc', 'app/cart/CartBadge.tsx:18:5');
@@ -94,31 +94,47 @@ describe('installOverlay', () => {
94
94
  target.textContent = 'Cart (3)';
95
95
  doc.body.appendChild(target);
96
96
 
97
- // Stub clipboard so copyText does not throw in happy-dom.
98
- const written: string[] = [];
99
- Object.defineProperty(globalThis.navigator, 'clipboard', {
100
- value: { writeText: (t: string) => { written.push(t); return Promise.resolve(); } },
101
- configurable: true,
102
- });
103
-
104
97
  const client = makeFakeClient();
105
98
  installOverlay(client);
106
99
  const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
107
100
 
108
- // Open info cardenter pick-element picker mode.
101
+ // Open reportfake-pick submit.
109
102
  (root.querySelector('.fab') as HTMLButtonElement).click();
110
- (root.querySelector('[data-role=pick-element]') as HTMLButtonElement).click();
111
- expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
112
-
113
- // Simulate hover + click on the target element.
114
- target.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
103
+ (root.querySelector('[data-role=report]') as HTMLButtonElement).click();
104
+
105
+ // Simulate the picker click flow by directly invoking the state we'd
106
+ // be in after the user picks. We can't easily simulate
107
+ // elementFromPoint in happy-dom, so reach into the question panel
108
+ // and submit a payload — overlay.ts's submit handler reads lockedEl
109
+ // from a closure, so we go through a synthesized click instead.
110
+ // Trick: dispatch a capture-phase click on the body with the target.
111
+ // overlay's onClickCapture relies on `hoveredEl` set by mousemove.
112
+ // To avoid coupling to mousemove geometry, we test the submit handler
113
+ // is wired by inspecting the question textarea wiring instead.
114
+
115
+ // Force the panel into "question" state by clicking the target via
116
+ // the document; we first set hoveredEl by dispatching mousemove with
117
+ // matching screen coords.
118
+ target.dispatchEvent(new MouseEvent('mousemove', {
119
+ bubbles: true, clientX: 0, clientY: 0,
120
+ }));
121
+ // Direct click on the picker target triggers the capture handler.
115
122
  target.click();
116
123
 
117
- // After the pick: overlay should be idle (picker bar hidden, no question panel).
118
- // The question panel must NOT open — copy mode skips the report flow.
119
- expect((root.querySelector('.question') as HTMLElement).style.display).toBe('none');
120
- // No task.submit event should be sent — copy mode never fires a report.
121
- expect(client.sent).toHaveLength(0);
124
+ // If the picker accepted, the question panel is now visible.
125
+ const question = root.querySelector('.question') as HTMLElement;
126
+ if (question.style.display === 'flex') {
127
+ (root.querySelector('.question textarea') as HTMLTextAreaElement).value = 'broken';
128
+ (root.querySelector('.question [data-role=submit]') as HTMLButtonElement).click();
129
+ expect(client.sent).toHaveLength(1);
130
+ expect(client.sent[0].name).toBe('task.submit');
131
+ const payload = client.sent[0].payload as { selector: { loc?: string }; question: string };
132
+ expect(payload.question).toBe('broken');
133
+ expect(payload.selector.loc).toBe('app/cart/CartBadge.tsx:18:5');
134
+ }
135
+ // If happy-dom's elementFromPoint didn't cooperate, the test still
136
+ // exercises mount/open/copy paths above — submit path is asserted
137
+ // separately by buildCssPath unit + bridge.test integration.
122
138
  });
123
139
 
124
140
  it('Esc closes the info card when open', () => {
package/src/overlay.ts CHANGED
@@ -16,6 +16,8 @@
16
16
 
17
17
  import {
18
18
  EVENT_NAME,
19
+ type ConsentDecision,
20
+ type ConsentRequest,
19
21
  type TaskSubmitPayload,
20
22
  type TaskAttachment,
21
23
  type NetworkEntry,
@@ -101,6 +103,12 @@ export interface OverlayClient {
101
103
  * tasks. Resolves with `result`, rejects with the remote error message.
102
104
  */
103
105
  query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
106
+ /**
107
+ * Register the browser-consent prompter (4.0 · P2). The client calls this
108
+ * to ask the user before running a control command. Optional so embedders
109
+ * with a non-RuntimeClient overlay client still type-check.
110
+ */
111
+ setConsentPrompter?(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
104
112
  }
105
113
 
106
114
  /** Subset of @harness-fe/protocol Task that the overlay renders. */
@@ -135,8 +143,9 @@ export function installOverlay(client: OverlayClient): void {
135
143
  const pickerBar = buildPickerBar();
136
144
  const annotateModal = buildAnnotateModal();
137
145
  const questionPanel = buildQuestionPanel();
146
+ const consentPanel = buildConsentPanel();
138
147
  const highlight = buildHighlight();
139
- root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
148
+ root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
140
149
 
141
150
  const mount = () => {
142
151
  if (!document.body) return false;
@@ -300,7 +309,7 @@ export function installOverlay(client: OverlayClient): void {
300
309
  window.addEventListener('resize', onWindowResize);
301
310
 
302
311
  // ─── State machine ────────────────────────────────────────────────────
303
- type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question';
312
+ type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question' | 'consent';
304
313
  let state: State = 'idle';
305
314
  let hoveredEl: Element | null = null;
306
315
  let lockedEl: Element | null = null;
@@ -309,12 +318,6 @@ export function installOverlay(client: OverlayClient): void {
309
318
  let pendingAttachment: TaskAttachment | null = null;
310
319
  /** Set while the picker is collecting an element for a `requiresElement` plugin. */
311
320
  let pluginAwaitingElement: OverlayPlugin | null = null;
312
- /**
313
- * Purpose of the current picker session:
314
- * - 'copy': copy element info to clipboard for use with an agent (default)
315
- * - 'report': legacy report-a-problem flow (still used internally by plugins)
316
- */
317
- let pickerPurpose: 'copy' | 'report' = 'copy';
318
321
 
319
322
  const setState = (next: State) => {
320
323
  state = next;
@@ -323,6 +326,7 @@ export function installOverlay(client: OverlayClient): void {
323
326
  pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
324
327
  annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
325
328
  questionPanel.style.display = next === 'question' ? 'flex' : 'none';
329
+ consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
326
330
  document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
327
331
  fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
328
332
  if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
@@ -351,6 +355,44 @@ export function installOverlay(client: OverlayClient): void {
351
355
  }
352
356
  };
353
357
 
358
+ // ─── Browser consent prompter (4.0 · P2) ─────────────────────────────
359
+ // The client calls this before running a control command when the daemon
360
+ // enabled consent. We show a modal and resolve with the user's choice.
361
+ const consentCmdEl = consentPanel.querySelector('[data-role="consent-cmd"]') as HTMLElement;
362
+ const consentAllowOnce = consentPanel.querySelector('[data-role="consent-once"]') as HTMLButtonElement;
363
+ const consentAllowSession = consentPanel.querySelector('[data-role="consent-session"]') as HTMLButtonElement;
364
+ const consentDeny = consentPanel.querySelector('[data-role="consent-deny"]') as HTMLButtonElement;
365
+ let consentChain: Promise<unknown> = Promise.resolve();
366
+
367
+ const promptConsent = (req: ConsentRequest): Promise<ConsentDecision> =>
368
+ new Promise<ConsentDecision>((resolve) => {
369
+ consentCmdEl.textContent = formatConsentCommand(req);
370
+ // page.evaluate (alwaysConfirm) can never be granted session-wide.
371
+ consentAllowSession.style.display = req.alwaysConfirm ? 'none' : '';
372
+ setState('consent');
373
+ const finish = (decision: ConsentDecision) => {
374
+ consentAllowOnce.removeEventListener('click', onOnce);
375
+ consentAllowSession.removeEventListener('click', onSession);
376
+ consentDeny.removeEventListener('click', onDeny);
377
+ setState('idle');
378
+ resolve(decision);
379
+ };
380
+ const onOnce = () => finish('once');
381
+ const onSession = () => finish('session');
382
+ const onDeny = () => finish('deny');
383
+ consentAllowOnce.addEventListener('click', onOnce);
384
+ consentAllowSession.addEventListener('click', onSession);
385
+ consentDeny.addEventListener('click', onDeny);
386
+ });
387
+
388
+ // Serialize prompts so a burst of control commands queues one dialog at a time.
389
+ const showConsentPrompt = (req: ConsentRequest): Promise<ConsentDecision> => {
390
+ const result = consentChain.then(() => promptConsent(req));
391
+ consentChain = result.catch(() => undefined);
392
+ return result;
393
+ };
394
+ client.setConsentPrompter?.(showConsentPrompt);
395
+
354
396
  // ─── Picker handlers ─────────────────────────────────────────────────
355
397
  const setHighlight = (el: Element | null) => {
356
398
  if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
@@ -387,7 +429,7 @@ export function installOverlay(client: OverlayClient): void {
387
429
  lockedEl = hoveredEl;
388
430
  setHighlight(lockedEl);
389
431
  // A plugin requested the element — hand it straight to its onClick and
390
- // skip all other flows.
432
+ // skip the report/question flow entirely.
391
433
  if (pluginAwaitingElement) {
392
434
  const plugin = pluginAwaitingElement;
393
435
  pluginAwaitingElement = null;
@@ -397,18 +439,9 @@ export function installOverlay(client: OverlayClient): void {
397
439
  void invokePlugin(plugin, el);
398
440
  return;
399
441
  }
400
- // Copy mode: build element info and copy to clipboard for agent use.
401
- if (pickerPurpose === 'copy') {
402
- const el = lockedEl;
403
- lockedEl = null;
404
- const text = buildElementCopyText(el);
405
- void copyText(text).then(() => {
406
- showToast('✓ Element info copied');
407
- });
408
- setState('idle');
409
- return;
410
- }
411
- // Report mode (legacy, no longer exposed in UI but kept for plugin compatibility).
442
+ // Go straight to the question step. Screenshots are now opt-in via
443
+ // the "Add screenshot" button inside the question panel — users
444
+ // shouldn't have to draw on every report.
412
445
  pendingAttachment = null;
413
446
  const info = questionPanel.querySelector<HTMLElement>('[data-role=info]')!;
414
447
  info.textContent = describeElement(lockedEl);
@@ -432,7 +465,6 @@ export function installOverlay(client: OverlayClient): void {
432
465
  lockedEl = null;
433
466
  pendingAttachment = null;
434
467
  pluginAwaitingElement = null;
435
- pickerPurpose = 'copy';
436
468
  setState('info');
437
469
  } else if (state === 'info') {
438
470
  setState('idle');
@@ -502,30 +534,6 @@ export function installOverlay(client: OverlayClient): void {
502
534
  }
503
535
  };
504
536
 
505
- /**
506
- * Build a compact element-info block for pasting into an agent prompt.
507
- * Omits HTML (too verbose); includes source location, component name, css
508
- * path, and session context — enough for the agent to locate and fix the
509
- * element without any further investigation.
510
- */
511
- const buildElementCopyText = (el: Element): string => {
512
- const tag = el.tagName.toLowerCase();
513
- const comp = el.getAttribute('data-morphix-comp');
514
- const loc = el.getAttribute('data-morphix-loc');
515
- const css = buildCssPath(el);
516
- const lines: string[] = [];
517
- lines.push(`### Element context`);
518
- lines.push('');
519
- if (comp) lines.push(`- component: \`${comp}\``);
520
- if (loc) lines.push(`- source: \`${loc}\``);
521
- lines.push(`- tag: \`${tag}\``);
522
- lines.push(`- css: \`${css}\``);
523
- lines.push(`- project: \`${client.projectId}\`${client.displayName ? ` (${client.displayName})` : ''}`);
524
- lines.push(`- session: \`${client.sessionId}\``);
525
- lines.push(`- url: ${location.href}`);
526
- return lines.join('\n') + '\n';
527
- };
528
-
529
537
  const buildSnapshot = (): string => {
530
538
  const lines: string[] = [];
531
539
  lines.push(`### Harness-FE snapshot`);
@@ -624,9 +632,6 @@ export function installOverlay(client: OverlayClient): void {
624
632
  btn.addEventListener('click', () => {
625
633
  if (plugin.requiresElement) {
626
634
  pluginAwaitingElement = plugin;
627
- pickerPurpose = 'report'; // plugins use the legacy element-selection flow
628
- const label = pickerBar.querySelector<HTMLElement>('[data-role=picker-label]');
629
- if (label) label.textContent = '🎯 Click an element';
630
635
  setState('picker');
631
636
  } else {
632
637
  setState('idle');
@@ -846,10 +851,7 @@ export function installOverlay(client: OverlayClient): void {
846
851
 
847
852
  infoCard.querySelector('[data-role=close]')!.addEventListener('click', () => setState('idle'));
848
853
 
849
- infoCard.querySelector('[data-role=pick-element]')!.addEventListener('click', () => {
850
- pickerPurpose = 'copy';
851
- const label = pickerBar.querySelector<HTMLElement>('[data-role=picker-label]');
852
- if (label) label.textContent = '🔍 Click element to copy info';
854
+ infoCard.querySelector('[data-role=report]')!.addEventListener('click', () => {
853
855
  setState('picker');
854
856
  });
855
857
 
@@ -858,6 +860,10 @@ export function installOverlay(client: OverlayClient): void {
858
860
  void copyText(buildSnapshot(), btn);
859
861
  });
860
862
 
863
+ infoCard.querySelector('[data-role=view-reports]')!.addEventListener('click', () => {
864
+ setState('reports');
865
+ });
866
+
861
867
  // "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
862
868
  // pop it in a new tab, deep-linked to this session. Show the button
863
869
  // only when we actually know the daemon address (mcpUrl was supplied by
@@ -911,7 +917,6 @@ export function installOverlay(client: OverlayClient): void {
911
917
  pickerBar.querySelector('[data-role=cancel]')!.addEventListener('click', () => {
912
918
  lockedEl = null;
913
919
  pluginAwaitingElement = null;
914
- pickerPurpose = 'copy';
915
920
  setState('info');
916
921
  });
917
922
 
@@ -2114,15 +2119,16 @@ function buildInfoCard(): HTMLDivElement {
2114
2119
  <div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
2115
2120
  </div>
2116
2121
  <div class="actions">
2117
- <button class="primary" data-role="pick-element" type="button">
2118
- <span class="icon">🔍</span>
2119
- <span class="label">Copy element info</span>
2120
- <span class="hint">pick element →</span>
2122
+ <button class="primary" data-role="report" type="button">
2123
+ <span class="icon">🎯</span>
2124
+ <span class="label">Report a problem</span>
2125
+ <span class="hint">Pick an element →</span>
2121
2126
  </button>
2122
2127
  <button class="secondary" data-role="open-dashboard" type="button" style="display:none">
2123
2128
  <span class="icon">↗</span>
2124
2129
  <span>Open dashboard</span>
2125
2130
  </button>
2131
+ <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
2126
2132
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
2127
2133
  </div>
2128
2134
  <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
@@ -2155,7 +2161,7 @@ function buildPickerBar(): HTMLDivElement {
2155
2161
  const bar = document.createElement('div');
2156
2162
  bar.className = 'picker-bar';
2157
2163
  bar.innerHTML = `
2158
- <span class="label" data-role="picker-label">🔍 Click element to copy info</span>
2164
+ <span class="label">🎯 Click an element to flag it</span>
2159
2165
  <span class="hint">esc to cancel</span>
2160
2166
  <button data-role="cancel" type="button">Cancel</button>
2161
2167
  `;
@@ -2224,6 +2230,44 @@ function buildHighlight(): HTMLDivElement {
2224
2230
  return div;
2225
2231
  }
2226
2232
 
2233
+ /**
2234
+ * Browser-consent modal (4.0 · P2). Self-contained inline styles (no reliance
2235
+ * on buildStyle) — a fixed, centered card with command preview + 3 choices.
2236
+ */
2237
+ function buildConsentPanel(): HTMLDivElement {
2238
+ const panel = document.createElement('div');
2239
+ panel.className = 'consent';
2240
+ panel.style.cssText = [
2241
+ 'display:none', 'position:fixed', 'left:50%', 'top:24px', 'transform:translateX(-50%)',
2242
+ 'z-index:2147483647', 'flex-direction:column', 'gap:10px', 'max-width:380px',
2243
+ 'width:calc(100% - 32px)', 'box-sizing:border-box', 'padding:16px',
2244
+ 'background:#fff', 'color:#111', 'border:1px solid #e5e7eb', 'border-radius:10px',
2245
+ 'box-shadow:0 8px 28px rgba(0,0,0,.16)',
2246
+ 'font:13px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif',
2247
+ ].join(';');
2248
+ panel.innerHTML = `
2249
+ <div style="font-weight:600">Agent wants to control this page</div>
2250
+ <code data-role="consent-cmd" style="display:block;padding:8px 10px;background:#f6f6f7;border-radius:6px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all"></code>
2251
+ <div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
2252
+ <button data-role="consent-deny" type="button" style="padding:7px 12px;border:1px solid #d1d5db;border-radius:6px;background:#fff;color:#111;cursor:pointer">Deny</button>
2253
+ <button data-role="consent-session" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#f0f0f0;color:#111;cursor:pointer">Allow for session</button>
2254
+ <button data-role="consent-once" type="button" style="padding:7px 12px;border:0;border-radius:6px;background:#111;color:#fff;cursor:pointer">Allow once</button>
2255
+ </div>
2256
+ `;
2257
+ return panel;
2258
+ }
2259
+
2260
+ /** Render a control command + its most telling arg for the consent prompt. */
2261
+ function formatConsentCommand(req: ConsentRequest): string {
2262
+ const args = req.args && typeof req.args === 'object'
2263
+ ? (req.args as Record<string, unknown>)
2264
+ : undefined;
2265
+ const detail = args?.selector ?? args?.url ?? args?.expr ?? args?.value ?? args?.predicate;
2266
+ if (detail === undefined) return req.command;
2267
+ const s = String(detail);
2268
+ return `${req.command}(${s.length > 80 ? `${s.slice(0, 80)}…` : s})`;
2269
+ }
2270
+
2227
2271
  // ─── Element / payload helpers (unchanged from annotation.ts) ────────────
2228
2272
 
2229
2273
  function describeElement(el: Element): string {