@harness-fe/runtime 3.4.1 → 4.0.0-next.4

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/index.js CHANGED
@@ -7,8 +7,7 @@
7
7
  import { installOverlay } from './overlay.js';
8
8
  import { RuntimeClient, readInjectedConfig } from './client.js';
9
9
  import { registerOverlayPlugin, drainPluginQueue, } from './pluginRegistry.js';
10
- // Informational; keep in sync with package.json on release.
11
- const VERSION = '3.3.0';
10
+ import { VERSION } from './version.js';
12
11
  const w = window;
13
12
  if (typeof window !== 'undefined' && !w.__harness_fe_started__) {
14
13
  w.__harness_fe_started__ = true;
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
@@ -16,6 +16,7 @@
16
16
  import { EVENT_NAME, } from '@harness-fe/protocol';
17
17
  import { snapdom } from '@zumer/snapdom';
18
18
  import { deriveDashboardUrl } from './dashboardUrl.js';
19
+ import { VERSION } from './version.js';
19
20
  import { getCaptureStore } from './capture.js';
20
21
  import { collectPageLoadSnapshot } from './snapshot.js';
21
22
  import { getOverlayPlugins, subscribeOverlayPlugins, } from './pluginRegistry.js';
@@ -77,8 +78,9 @@ export function installOverlay(client) {
77
78
  const pickerBar = buildPickerBar();
78
79
  const annotateModal = buildAnnotateModal();
79
80
  const questionPanel = buildQuestionPanel();
81
+ const consentPanel = buildConsentPanel();
80
82
  const highlight = buildHighlight();
81
- root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
83
+ root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
82
84
  const mount = () => {
83
85
  if (!document.body)
84
86
  return false;
@@ -249,12 +251,6 @@ export function installOverlay(client) {
249
251
  let pendingAttachment = null;
250
252
  /** Set while the picker is collecting an element for a `requiresElement` plugin. */
251
253
  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
254
  const setState = (next) => {
259
255
  state = next;
260
256
  infoCard.style.display = next === 'info' ? 'flex' : 'none';
@@ -262,6 +258,7 @@ export function installOverlay(client) {
262
258
  pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
263
259
  annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
264
260
  questionPanel.style.display = next === 'question' ? 'flex' : 'none';
261
+ consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
265
262
  document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
266
263
  fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
267
264
  if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
@@ -290,6 +287,40 @@ export function installOverlay(client) {
290
287
  requestAnimationFrame(() => repositionCards());
291
288
  }
292
289
  };
290
+ // ─── Browser consent prompter (4.0 · P2) ─────────────────────────────
291
+ // The client calls this before running a control command when the daemon
292
+ // enabled consent. We show a modal and resolve with the user's choice.
293
+ const consentCmdEl = consentPanel.querySelector('[data-role="consent-cmd"]');
294
+ const consentAllowOnce = consentPanel.querySelector('[data-role="consent-once"]');
295
+ const consentAllowSession = consentPanel.querySelector('[data-role="consent-session"]');
296
+ const consentDeny = consentPanel.querySelector('[data-role="consent-deny"]');
297
+ let consentChain = Promise.resolve();
298
+ const promptConsent = (req) => new Promise((resolve) => {
299
+ consentCmdEl.textContent = formatConsentCommand(req);
300
+ // page.evaluate (alwaysConfirm) can never be granted session-wide.
301
+ consentAllowSession.style.display = req.alwaysConfirm ? 'none' : '';
302
+ setState('consent');
303
+ const finish = (decision) => {
304
+ consentAllowOnce.removeEventListener('click', onOnce);
305
+ consentAllowSession.removeEventListener('click', onSession);
306
+ consentDeny.removeEventListener('click', onDeny);
307
+ setState('idle');
308
+ resolve(decision);
309
+ };
310
+ const onOnce = () => finish('once');
311
+ const onSession = () => finish('session');
312
+ const onDeny = () => finish('deny');
313
+ consentAllowOnce.addEventListener('click', onOnce);
314
+ consentAllowSession.addEventListener('click', onSession);
315
+ consentDeny.addEventListener('click', onDeny);
316
+ });
317
+ // Serialize prompts so a burst of control commands queues one dialog at a time.
318
+ const showConsentPrompt = (req) => {
319
+ const result = consentChain.then(() => promptConsent(req));
320
+ consentChain = result.catch(() => undefined);
321
+ return result;
322
+ };
323
+ client.setConsentPrompter?.(showConsentPrompt);
293
324
  // ─── Picker handlers ─────────────────────────────────────────────────
294
325
  const setHighlight = (el) => {
295
326
  if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
@@ -328,7 +359,7 @@ export function installOverlay(client) {
328
359
  lockedEl = hoveredEl;
329
360
  setHighlight(lockedEl);
330
361
  // A plugin requested the element — hand it straight to its onClick and
331
- // skip all other flows.
362
+ // skip the report/question flow entirely.
332
363
  if (pluginAwaitingElement) {
333
364
  const plugin = pluginAwaitingElement;
334
365
  pluginAwaitingElement = null;
@@ -338,18 +369,9 @@ export function installOverlay(client) {
338
369
  void invokePlugin(plugin, el);
339
370
  return;
340
371
  }
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).
372
+ // Go straight to the question step. Screenshots are now opt-in via
373
+ // the "Add screenshot" button inside the question panel — users
374
+ // shouldn't have to draw on every report.
353
375
  pendingAttachment = null;
354
376
  const info = questionPanel.querySelector('[data-role=info]');
355
377
  info.textContent = describeElement(lockedEl);
@@ -373,7 +395,6 @@ export function installOverlay(client) {
373
395
  lockedEl = null;
374
396
  pendingAttachment = null;
375
397
  pluginAwaitingElement = null;
376
- pickerPurpose = 'copy';
377
398
  setState('info');
378
399
  }
379
400
  else if (state === 'info') {
@@ -391,11 +412,13 @@ export function installOverlay(client) {
391
412
  // ─── Info card rendering ─────────────────────────────────────────────
392
413
  const renderInfo = () => {
393
414
  const proj = infoCard.querySelector('[data-role=project]');
415
+ const version = infoCard.querySelector('[data-role=version]');
394
416
  const build = infoCard.querySelector('[data-role=build]');
395
417
  const session = infoCard.querySelector('[data-role=session]');
396
418
  const tab = infoCard.querySelector('[data-role=tab]');
397
419
  const url = infoCard.querySelector('[data-role=url]');
398
420
  proj.textContent = client.displayName ?? client.projectId;
421
+ version.textContent = `v${VERSION}`;
399
422
  build.textContent = client.buildId ? abbr(client.buildId) : '—';
400
423
  build.title = client.buildId ?? 'No buildId — set HarnessScript buildId prop in prod';
401
424
  session.textContent = abbr(client.sessionId);
@@ -444,31 +467,6 @@ export function installOverlay(client) {
444
467
  }, 1200);
445
468
  }
446
469
  };
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
470
  const buildSnapshot = () => {
473
471
  const lines = [];
474
472
  lines.push(`### Harness-FE snapshot`);
@@ -566,10 +564,6 @@ export function installOverlay(client) {
566
564
  btn.addEventListener('click', () => {
567
565
  if (plugin.requiresElement) {
568
566
  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
567
  setState('picker');
574
568
  }
575
569
  else {
@@ -798,17 +792,16 @@ export function installOverlay(client) {
798
792
  setState(state === 'idle' ? 'info' : 'idle');
799
793
  });
800
794
  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';
795
+ infoCard.querySelector('[data-role=report]').addEventListener('click', () => {
806
796
  setState('picker');
807
797
  });
808
798
  infoCard.querySelector('[data-role=copy-snapshot]').addEventListener('click', (ev) => {
809
799
  const btn = ev.currentTarget;
810
800
  void copyText(buildSnapshot(), btn);
811
801
  });
802
+ infoCard.querySelector('[data-role=view-reports]').addEventListener('click', () => {
803
+ setState('reports');
804
+ });
812
805
  // "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
813
806
  // pop it in a new tab, deep-linked to this session. Show the button
814
807
  // only when we actually know the daemon address (mcpUrl was supplied by
@@ -861,7 +854,6 @@ export function installOverlay(client) {
861
854
  pickerBar.querySelector('[data-role=cancel]').addEventListener('click', () => {
862
855
  lockedEl = null;
863
856
  pluginAwaitingElement = null;
864
- pickerPurpose = 'copy';
865
857
  setState('info');
866
858
  });
867
859
  questionPanel.querySelector('[data-role=cancel]').addEventListener('click', () => {
@@ -1980,21 +1972,23 @@ function buildInfoCard() {
1980
1972
  <button class="close-btn" data-role="close" title="Close (Esc)" type="button">×</button>
1981
1973
  </div>
1982
1974
  <div class="rows">
1975
+ <div class="row"><span class="key">version</span><span class="pill" data-role="version" title="harness runtime version"></span></div>
1983
1976
  <div class="row"><span class="key">build</span><span class="pill" data-role="build" title="Click to copy"></span></div>
1984
1977
  <div class="row"><span class="key">session</span><span class="pill" data-role="session" title="Click to copy"></span></div>
1985
1978
  <div class="row"><span class="key">tab</span><span class="pill" data-role="tab" title="Click to copy"></span></div>
1986
1979
  <div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
1987
1980
  </div>
1988
1981
  <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>
1982
+ <button class="primary" data-role="report" type="button">
1983
+ <span class="icon">🎯</span>
1984
+ <span class="label">Report a problem</span>
1985
+ <span class="hint">Pick an element →</span>
1993
1986
  </button>
1994
1987
  <button class="secondary" data-role="open-dashboard" type="button" style="display:none">
1995
1988
  <span class="icon">↗</span>
1996
1989
  <span>Open dashboard</span>
1997
1990
  </button>
1991
+ <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
1998
1992
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
1999
1993
  </div>
2000
1994
  <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
@@ -2025,7 +2019,7 @@ function buildPickerBar() {
2025
2019
  const bar = document.createElement('div');
2026
2020
  bar.className = 'picker-bar';
2027
2021
  bar.innerHTML = `
2028
- <span class="label" data-role="picker-label">🔍 Click element to copy info</span>
2022
+ <span class="label">🎯 Click an element to flag it</span>
2029
2023
  <span class="hint">esc to cancel</span>
2030
2024
  <button data-role="cancel" type="button">Cancel</button>
2031
2025
  `;
@@ -2090,6 +2084,43 @@ function buildHighlight() {
2090
2084
  div.className = 'highlight';
2091
2085
  return div;
2092
2086
  }
2087
+ /**
2088
+ * Browser-consent modal (4.0 · P2). Self-contained inline styles (no reliance
2089
+ * on buildStyle) — a fixed, centered card with command preview + 3 choices.
2090
+ */
2091
+ function buildConsentPanel() {
2092
+ const panel = document.createElement('div');
2093
+ panel.className = 'consent';
2094
+ panel.style.cssText = [
2095
+ 'display:none', 'position:fixed', 'left:50%', 'top:24px', 'transform:translateX(-50%)',
2096
+ 'z-index:2147483647', 'flex-direction:column', 'gap:10px', 'max-width:380px',
2097
+ 'width:calc(100% - 32px)', 'box-sizing:border-box', 'padding:16px',
2098
+ 'background:#fff', 'color:#111', 'border:1px solid #e5e7eb', 'border-radius:10px',
2099
+ 'box-shadow:0 8px 28px rgba(0,0,0,.16)',
2100
+ 'font:13px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif',
2101
+ ].join(';');
2102
+ panel.innerHTML = `
2103
+ <div style="font-weight:600">Agent wants to control this page</div>
2104
+ <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>
2105
+ <div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
2106
+ <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>
2107
+ <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>
2108
+ <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>
2109
+ </div>
2110
+ `;
2111
+ return panel;
2112
+ }
2113
+ /** Render a control command + its most telling arg for the consent prompt. */
2114
+ function formatConsentCommand(req) {
2115
+ const args = req.args && typeof req.args === 'object'
2116
+ ? req.args
2117
+ : undefined;
2118
+ const detail = args?.selector ?? args?.url ?? args?.expr ?? args?.value ?? args?.predicate;
2119
+ if (detail === undefined)
2120
+ return req.command;
2121
+ const s = String(detail);
2122
+ return `${req.command}(${s.length > 80 ? `${s.slice(0, 80)}…` : s})`;
2123
+ }
2093
2124
  // ─── Element / payload helpers (unchanged from annotation.ts) ────────────
2094
2125
  function describeElement(el) {
2095
2126
  const tag = el.tagName.toLowerCase();
@@ -0,0 +1 @@
1
+ export declare const VERSION = "4.0.0-next.4";
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by scripts/gen-version.mjs — do not edit by hand.
2
+ // Sourced from package.json at build time so the runtime reports its real version.
3
+ export const VERSION = '4.0.0-next.4';
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.4",
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.4",
34
34
  "@harness-fe/sandbox": "^3.2.0"
35
35
  },
36
36
  "devDependencies": {
@@ -42,9 +42,9 @@
42
42
  "access": "public"
43
43
  },
44
44
  "scripts": {
45
- "build": "tsc",
46
- "dev": "tsc --watch --preserveWatchOutput",
47
- "watch": "tsc --watch --preserveWatchOutput",
45
+ "build": "node scripts/gen-version.mjs && tsc",
46
+ "dev": "node scripts/gen-version.mjs && tsc --watch --preserveWatchOutput",
47
+ "watch": "node scripts/gen-version.mjs && tsc --watch --preserveWatchOutput",
48
48
  "typecheck": "tsc --noEmit",
49
49
  "test": "vitest run"
50
50
  }
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
+ });
package/src/index.ts CHANGED
@@ -12,9 +12,7 @@ import {
12
12
  drainPluginQueue,
13
13
  type OverlayPlugin,
14
14
  } from './pluginRegistry.js';
15
-
16
- // Informational; keep in sync with package.json on release.
17
- const VERSION = '3.3.0';
15
+ import { VERSION } from './version.js';
18
16
 
19
17
  const w = window as unknown as {
20
18
  __harness_fe_started__?: boolean;
@@ -58,6 +58,8 @@ describe('installOverlay', () => {
58
58
  const card = root.querySelector('.info-card') as HTMLElement;
59
59
  expect(card.style.display).toBe('flex');
60
60
  expect(root.querySelector('[data-role=project]')!.textContent).toBe('Demo App');
61
+ // Runtime version surfaced in the card (real value from version.ts).
62
+ expect(root.querySelector('[data-role=version]')!.textContent).toMatch(/^v\d/);
61
63
  // Abbreviated to 8 chars
62
64
  expect(root.querySelector('[data-role=build]')!.textContent).toBe('build-12');
63
65
  expect(root.querySelector('[data-role=session]')!.textContent).toBe('sess-123');
@@ -73,20 +75,20 @@ describe('installOverlay', () => {
73
75
  expect(root.querySelector('[data-role=build]')!.textContent).toBe('—');
74
76
  });
75
77
 
76
- it('"Copy element info" enters picker mode (FAB turns active, info card hidden)', () => {
78
+ it('"Report a problem" enters picker mode (FAB turns active, info card hidden)', () => {
77
79
  setupDom();
78
80
  const client = makeFakeClient();
79
81
  installOverlay(client);
80
82
  const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
81
83
  (root.querySelector('.fab') as HTMLButtonElement).click();
82
- (root.querySelector('[data-role=pick-element]') as HTMLButtonElement).click();
84
+ (root.querySelector('[data-role=report]') as HTMLButtonElement).click();
83
85
  const fab = root.querySelector('.fab') as HTMLButtonElement;
84
86
  expect(fab.dataset.state).toBe('active');
85
87
  expect((root.querySelector('.info-card') as HTMLElement).style.display).toBe('none');
86
88
  expect((root.querySelector('.picker-bar') as HTMLElement).style.display).toBe('flex');
87
89
  });
88
90
 
89
- it('"Copy element info" returns to idle after element click (no task.submit fired)', () => {
91
+ it('submits a task.submit event payload with selector + element on Submit', () => {
90
92
  const { doc } = setupDom();
91
93
  const target = doc.createElement('button');
92
94
  target.setAttribute('data-morphix-loc', 'app/cart/CartBadge.tsx:18:5');
@@ -94,31 +96,47 @@ describe('installOverlay', () => {
94
96
  target.textContent = 'Cart (3)';
95
97
  doc.body.appendChild(target);
96
98
 
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
99
  const client = makeFakeClient();
105
100
  installOverlay(client);
106
101
  const root = document.getElementById('__harness_fe_overlay__')!.shadowRoot!;
107
102
 
108
- // Open info cardenter pick-element picker mode.
103
+ // Open reportfake-pick submit.
109
104
  (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 }));
105
+ (root.querySelector('[data-role=report]') as HTMLButtonElement).click();
106
+
107
+ // Simulate the picker click flow by directly invoking the state we'd
108
+ // be in after the user picks. We can't easily simulate
109
+ // elementFromPoint in happy-dom, so reach into the question panel
110
+ // and submit a payload — overlay.ts's submit handler reads lockedEl
111
+ // from a closure, so we go through a synthesized click instead.
112
+ // Trick: dispatch a capture-phase click on the body with the target.
113
+ // overlay's onClickCapture relies on `hoveredEl` set by mousemove.
114
+ // To avoid coupling to mousemove geometry, we test the submit handler
115
+ // is wired by inspecting the question textarea wiring instead.
116
+
117
+ // Force the panel into "question" state by clicking the target via
118
+ // the document; we first set hoveredEl by dispatching mousemove with
119
+ // matching screen coords.
120
+ target.dispatchEvent(new MouseEvent('mousemove', {
121
+ bubbles: true, clientX: 0, clientY: 0,
122
+ }));
123
+ // Direct click on the picker target triggers the capture handler.
115
124
  target.click();
116
125
 
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);
126
+ // If the picker accepted, the question panel is now visible.
127
+ const question = root.querySelector('.question') as HTMLElement;
128
+ if (question.style.display === 'flex') {
129
+ (root.querySelector('.question textarea') as HTMLTextAreaElement).value = 'broken';
130
+ (root.querySelector('.question [data-role=submit]') as HTMLButtonElement).click();
131
+ expect(client.sent).toHaveLength(1);
132
+ expect(client.sent[0].name).toBe('task.submit');
133
+ const payload = client.sent[0].payload as { selector: { loc?: string }; question: string };
134
+ expect(payload.question).toBe('broken');
135
+ expect(payload.selector.loc).toBe('app/cart/CartBadge.tsx:18:5');
136
+ }
137
+ // If happy-dom's elementFromPoint didn't cooperate, the test still
138
+ // exercises mount/open/copy paths above — submit path is asserted
139
+ // separately by buildCssPath unit + bridge.test integration.
122
140
  });
123
141
 
124
142
  it('Esc closes the info card when open', () => {
package/src/overlay.ts CHANGED
@@ -16,12 +16,15 @@
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,
22
24
  } from '@harness-fe/protocol';
23
25
  import { snapdom } from '@zumer/snapdom';
24
26
  import { deriveDashboardUrl } from './dashboardUrl.js';
27
+ import { VERSION } from './version.js';
25
28
  import { getCaptureStore } from './capture.js';
26
29
  import { collectPageLoadSnapshot } from './snapshot.js';
27
30
  import {
@@ -101,6 +104,12 @@ export interface OverlayClient {
101
104
  * tasks. Resolves with `result`, rejects with the remote error message.
102
105
  */
103
106
  query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
107
+ /**
108
+ * Register the browser-consent prompter (4.0 · P2). The client calls this
109
+ * to ask the user before running a control command. Optional so embedders
110
+ * with a non-RuntimeClient overlay client still type-check.
111
+ */
112
+ setConsentPrompter?(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
104
113
  }
105
114
 
106
115
  /** Subset of @harness-fe/protocol Task that the overlay renders. */
@@ -135,8 +144,9 @@ export function installOverlay(client: OverlayClient): void {
135
144
  const pickerBar = buildPickerBar();
136
145
  const annotateModal = buildAnnotateModal();
137
146
  const questionPanel = buildQuestionPanel();
147
+ const consentPanel = buildConsentPanel();
138
148
  const highlight = buildHighlight();
139
- root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
149
+ root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
140
150
 
141
151
  const mount = () => {
142
152
  if (!document.body) return false;
@@ -300,7 +310,7 @@ export function installOverlay(client: OverlayClient): void {
300
310
  window.addEventListener('resize', onWindowResize);
301
311
 
302
312
  // ─── State machine ────────────────────────────────────────────────────
303
- type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question';
313
+ type State = 'idle' | 'info' | 'reports' | 'picker' | 'annotate' | 'question' | 'consent';
304
314
  let state: State = 'idle';
305
315
  let hoveredEl: Element | null = null;
306
316
  let lockedEl: Element | null = null;
@@ -309,12 +319,6 @@ export function installOverlay(client: OverlayClient): void {
309
319
  let pendingAttachment: TaskAttachment | null = null;
310
320
  /** Set while the picker is collecting an element for a `requiresElement` plugin. */
311
321
  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
322
 
319
323
  const setState = (next: State) => {
320
324
  state = next;
@@ -323,6 +327,7 @@ export function installOverlay(client: OverlayClient): void {
323
327
  pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
324
328
  annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
325
329
  questionPanel.style.display = next === 'question' ? 'flex' : 'none';
330
+ consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
326
331
  document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
327
332
  fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
328
333
  if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
@@ -351,6 +356,44 @@ export function installOverlay(client: OverlayClient): void {
351
356
  }
352
357
  };
353
358
 
359
+ // ─── Browser consent prompter (4.0 · P2) ─────────────────────────────
360
+ // The client calls this before running a control command when the daemon
361
+ // enabled consent. We show a modal and resolve with the user's choice.
362
+ const consentCmdEl = consentPanel.querySelector('[data-role="consent-cmd"]') as HTMLElement;
363
+ const consentAllowOnce = consentPanel.querySelector('[data-role="consent-once"]') as HTMLButtonElement;
364
+ const consentAllowSession = consentPanel.querySelector('[data-role="consent-session"]') as HTMLButtonElement;
365
+ const consentDeny = consentPanel.querySelector('[data-role="consent-deny"]') as HTMLButtonElement;
366
+ let consentChain: Promise<unknown> = Promise.resolve();
367
+
368
+ const promptConsent = (req: ConsentRequest): Promise<ConsentDecision> =>
369
+ new Promise<ConsentDecision>((resolve) => {
370
+ consentCmdEl.textContent = formatConsentCommand(req);
371
+ // page.evaluate (alwaysConfirm) can never be granted session-wide.
372
+ consentAllowSession.style.display = req.alwaysConfirm ? 'none' : '';
373
+ setState('consent');
374
+ const finish = (decision: ConsentDecision) => {
375
+ consentAllowOnce.removeEventListener('click', onOnce);
376
+ consentAllowSession.removeEventListener('click', onSession);
377
+ consentDeny.removeEventListener('click', onDeny);
378
+ setState('idle');
379
+ resolve(decision);
380
+ };
381
+ const onOnce = () => finish('once');
382
+ const onSession = () => finish('session');
383
+ const onDeny = () => finish('deny');
384
+ consentAllowOnce.addEventListener('click', onOnce);
385
+ consentAllowSession.addEventListener('click', onSession);
386
+ consentDeny.addEventListener('click', onDeny);
387
+ });
388
+
389
+ // Serialize prompts so a burst of control commands queues one dialog at a time.
390
+ const showConsentPrompt = (req: ConsentRequest): Promise<ConsentDecision> => {
391
+ const result = consentChain.then(() => promptConsent(req));
392
+ consentChain = result.catch(() => undefined);
393
+ return result;
394
+ };
395
+ client.setConsentPrompter?.(showConsentPrompt);
396
+
354
397
  // ─── Picker handlers ─────────────────────────────────────────────────
355
398
  const setHighlight = (el: Element | null) => {
356
399
  if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
@@ -387,7 +430,7 @@ export function installOverlay(client: OverlayClient): void {
387
430
  lockedEl = hoveredEl;
388
431
  setHighlight(lockedEl);
389
432
  // A plugin requested the element — hand it straight to its onClick and
390
- // skip all other flows.
433
+ // skip the report/question flow entirely.
391
434
  if (pluginAwaitingElement) {
392
435
  const plugin = pluginAwaitingElement;
393
436
  pluginAwaitingElement = null;
@@ -397,18 +440,9 @@ export function installOverlay(client: OverlayClient): void {
397
440
  void invokePlugin(plugin, el);
398
441
  return;
399
442
  }
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).
443
+ // Go straight to the question step. Screenshots are now opt-in via
444
+ // the "Add screenshot" button inside the question panel — users
445
+ // shouldn't have to draw on every report.
412
446
  pendingAttachment = null;
413
447
  const info = questionPanel.querySelector<HTMLElement>('[data-role=info]')!;
414
448
  info.textContent = describeElement(lockedEl);
@@ -432,7 +466,6 @@ export function installOverlay(client: OverlayClient): void {
432
466
  lockedEl = null;
433
467
  pendingAttachment = null;
434
468
  pluginAwaitingElement = null;
435
- pickerPurpose = 'copy';
436
469
  setState('info');
437
470
  } else if (state === 'info') {
438
471
  setState('idle');
@@ -450,11 +483,13 @@ export function installOverlay(client: OverlayClient): void {
450
483
  // ─── Info card rendering ─────────────────────────────────────────────
451
484
  const renderInfo = () => {
452
485
  const proj = infoCard.querySelector<HTMLElement>('[data-role=project]')!;
486
+ const version = infoCard.querySelector<HTMLElement>('[data-role=version]')!;
453
487
  const build = infoCard.querySelector<HTMLElement>('[data-role=build]')!;
454
488
  const session = infoCard.querySelector<HTMLElement>('[data-role=session]')!;
455
489
  const tab = infoCard.querySelector<HTMLElement>('[data-role=tab]')!;
456
490
  const url = infoCard.querySelector<HTMLElement>('[data-role=url]')!;
457
491
  proj.textContent = client.displayName ?? client.projectId;
492
+ version.textContent = `v${VERSION}`;
458
493
  build.textContent = client.buildId ? abbr(client.buildId) : '—';
459
494
  build.title = client.buildId ?? 'No buildId — set HarnessScript buildId prop in prod';
460
495
  session.textContent = abbr(client.sessionId);
@@ -502,30 +537,6 @@ export function installOverlay(client: OverlayClient): void {
502
537
  }
503
538
  };
504
539
 
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
540
  const buildSnapshot = (): string => {
530
541
  const lines: string[] = [];
531
542
  lines.push(`### Harness-FE snapshot`);
@@ -624,9 +635,6 @@ export function installOverlay(client: OverlayClient): void {
624
635
  btn.addEventListener('click', () => {
625
636
  if (plugin.requiresElement) {
626
637
  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
638
  setState('picker');
631
639
  } else {
632
640
  setState('idle');
@@ -846,10 +854,7 @@ export function installOverlay(client: OverlayClient): void {
846
854
 
847
855
  infoCard.querySelector('[data-role=close]')!.addEventListener('click', () => setState('idle'));
848
856
 
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';
857
+ infoCard.querySelector('[data-role=report]')!.addEventListener('click', () => {
853
858
  setState('picker');
854
859
  });
855
860
 
@@ -858,6 +863,10 @@ export function installOverlay(client: OverlayClient): void {
858
863
  void copyText(buildSnapshot(), btn);
859
864
  });
860
865
 
866
+ infoCard.querySelector('[data-role=view-reports]')!.addEventListener('click', () => {
867
+ setState('reports');
868
+ });
869
+
861
870
  // "Open dashboard" — derive the daemon's dashboard URL from mcpUrl and
862
871
  // pop it in a new tab, deep-linked to this session. Show the button
863
872
  // only when we actually know the daemon address (mcpUrl was supplied by
@@ -911,7 +920,6 @@ export function installOverlay(client: OverlayClient): void {
911
920
  pickerBar.querySelector('[data-role=cancel]')!.addEventListener('click', () => {
912
921
  lockedEl = null;
913
922
  pluginAwaitingElement = null;
914
- pickerPurpose = 'copy';
915
923
  setState('info');
916
924
  });
917
925
 
@@ -2108,21 +2116,23 @@ function buildInfoCard(): HTMLDivElement {
2108
2116
  <button class="close-btn" data-role="close" title="Close (Esc)" type="button">×</button>
2109
2117
  </div>
2110
2118
  <div class="rows">
2119
+ <div class="row"><span class="key">version</span><span class="pill" data-role="version" title="harness runtime version"></span></div>
2111
2120
  <div class="row"><span class="key">build</span><span class="pill" data-role="build" title="Click to copy"></span></div>
2112
2121
  <div class="row"><span class="key">session</span><span class="pill" data-role="session" title="Click to copy"></span></div>
2113
2122
  <div class="row"><span class="key">tab</span><span class="pill" data-role="tab" title="Click to copy"></span></div>
2114
2123
  <div class="row"><span class="key">url</span><span class="pill url" data-role="url"></span></div>
2115
2124
  </div>
2116
2125
  <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>
2126
+ <button class="primary" data-role="report" type="button">
2127
+ <span class="icon">🎯</span>
2128
+ <span class="label">Report a problem</span>
2129
+ <span class="hint">Pick an element →</span>
2121
2130
  </button>
2122
2131
  <button class="secondary" data-role="open-dashboard" type="button" style="display:none">
2123
2132
  <span class="icon">↗</span>
2124
2133
  <span>Open dashboard</span>
2125
2134
  </button>
2135
+ <button class="secondary" data-role="view-reports" type="button">📁 My reports</button>
2126
2136
  <button class="secondary" data-role="copy-snapshot" type="button">📋 Copy snapshot</button>
2127
2137
  </div>
2128
2138
  <div class="plugin-actions" data-role="plugin-actions" style="display:none"></div>
@@ -2155,7 +2165,7 @@ function buildPickerBar(): HTMLDivElement {
2155
2165
  const bar = document.createElement('div');
2156
2166
  bar.className = 'picker-bar';
2157
2167
  bar.innerHTML = `
2158
- <span class="label" data-role="picker-label">🔍 Click element to copy info</span>
2168
+ <span class="label">🎯 Click an element to flag it</span>
2159
2169
  <span class="hint">esc to cancel</span>
2160
2170
  <button data-role="cancel" type="button">Cancel</button>
2161
2171
  `;
@@ -2224,6 +2234,44 @@ function buildHighlight(): HTMLDivElement {
2224
2234
  return div;
2225
2235
  }
2226
2236
 
2237
+ /**
2238
+ * Browser-consent modal (4.0 · P2). Self-contained inline styles (no reliance
2239
+ * on buildStyle) — a fixed, centered card with command preview + 3 choices.
2240
+ */
2241
+ function buildConsentPanel(): HTMLDivElement {
2242
+ const panel = document.createElement('div');
2243
+ panel.className = 'consent';
2244
+ panel.style.cssText = [
2245
+ 'display:none', 'position:fixed', 'left:50%', 'top:24px', 'transform:translateX(-50%)',
2246
+ 'z-index:2147483647', 'flex-direction:column', 'gap:10px', 'max-width:380px',
2247
+ 'width:calc(100% - 32px)', 'box-sizing:border-box', 'padding:16px',
2248
+ 'background:#fff', 'color:#111', 'border:1px solid #e5e7eb', 'border-radius:10px',
2249
+ 'box-shadow:0 8px 28px rgba(0,0,0,.16)',
2250
+ 'font:13px/1.45 -apple-system,BlinkMacSystemFont,system-ui,sans-serif',
2251
+ ].join(';');
2252
+ panel.innerHTML = `
2253
+ <div style="font-weight:600">Agent wants to control this page</div>
2254
+ <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>
2255
+ <div style="display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap">
2256
+ <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>
2257
+ <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>
2258
+ <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>
2259
+ </div>
2260
+ `;
2261
+ return panel;
2262
+ }
2263
+
2264
+ /** Render a control command + its most telling arg for the consent prompt. */
2265
+ function formatConsentCommand(req: ConsentRequest): string {
2266
+ const args = req.args && typeof req.args === 'object'
2267
+ ? (req.args as Record<string, unknown>)
2268
+ : undefined;
2269
+ const detail = args?.selector ?? args?.url ?? args?.expr ?? args?.value ?? args?.predicate;
2270
+ if (detail === undefined) return req.command;
2271
+ const s = String(detail);
2272
+ return `${req.command}(${s.length > 80 ? `${s.slice(0, 80)}…` : s})`;
2273
+ }
2274
+
2227
2275
  // ─── Element / payload helpers (unchanged from annotation.ts) ────────────
2228
2276
 
2229
2277
  function describeElement(el: Element): string {
@@ -24,11 +24,11 @@ vi.mock('rrweb', () => ({
24
24
  import { mkdtempSync, rmSync } from 'node:fs';
25
25
  import { tmpdir } from 'node:os';
26
26
  import { join } from 'node:path';
27
- import { Bridge } from '../../mcp-server/src/bridge.js';
28
- import { JsonlStore } from '../../mcp-server/src/store/index.js';
27
+ import { Bridge } from '../../daemon/src/bridge.js';
28
+ import { JsonlStore } from '../../daemon/src/store/index.js';
29
29
  import { RuntimeClient } from './client.js';
30
30
  import { getCaptureStore } from './capture.js';
31
- import type { StoreEvent } from '../../mcp-server/src/store/index.js';
31
+ import type { StoreEvent } from '../../daemon/src/store/index.js';
32
32
  import type { NetworkEntry, StorageEntry, WsEntry } from '@harness-fe/protocol';
33
33
 
34
34
  interface Env {
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ // AUTO-GENERATED by scripts/gen-version.mjs — do not edit by hand.
2
+ // Sourced from package.json at build time so the runtime reports its real version.
3
+ export const VERSION = '4.0.0-next.4';