@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 +1 -1
- package/dist/client.d.ts +18 -1
- package/dist/client.js +57 -1
- package/dist/overlay.d.ts +7 -1
- package/dist/overlay.js +88 -61
- package/package.json +2 -2
- package/src/client.ts +60 -0
- package/src/consentGate.test.ts +115 -0
- package/src/overlay.test.ts +37 -21
- package/src/overlay.ts +103 -59
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: "
|
|
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
|
|
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
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
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=
|
|
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="
|
|
1990
|
-
<span class="icon"
|
|
1991
|
-
<span class="label">
|
|
1992
|
-
<span class="hint">
|
|
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"
|
|
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
|
+
"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": "
|
|
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
|
+
});
|
package/src/overlay.test.ts
CHANGED
|
@@ -73,20 +73,20 @@ describe('installOverlay', () => {
|
|
|
73
73
|
expect(root.querySelector('[data-role=build]')!.textContent).toBe('—');
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
-
it('"
|
|
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=
|
|
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('
|
|
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
|
|
101
|
+
// Open → report → fake-pick → submit.
|
|
109
102
|
(root.querySelector('.fab') as HTMLButtonElement).click();
|
|
110
|
-
(root.querySelector('[data-role=
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
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=
|
|
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="
|
|
2118
|
-
<span class="icon"
|
|
2119
|
-
<span class="label">
|
|
2120
|
-
<span class="hint">
|
|
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"
|
|
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 {
|