@harness-fe/runtime 3.4.0 → 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 +74 -1
- package/package.json +3 -3
- package/src/client.ts +60 -0
- package/src/consentGate.test.ts +115 -0
- package/src/overlay.ts +88 -2
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;
|
|
@@ -256,6 +257,7 @@ export function installOverlay(client) {
|
|
|
256
257
|
pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
|
|
257
258
|
annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
|
|
258
259
|
questionPanel.style.display = next === 'question' ? 'flex' : 'none';
|
|
260
|
+
consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
|
|
259
261
|
document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
|
|
260
262
|
fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
|
|
261
263
|
if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
|
|
@@ -284,6 +286,40 @@ export function installOverlay(client) {
|
|
|
284
286
|
requestAnimationFrame(() => repositionCards());
|
|
285
287
|
}
|
|
286
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);
|
|
287
323
|
// ─── Picker handlers ─────────────────────────────────────────────────
|
|
288
324
|
const setHighlight = (el) => {
|
|
289
325
|
if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
|
|
@@ -2044,6 +2080,43 @@ function buildHighlight() {
|
|
|
2044
2080
|
div.className = 'highlight';
|
|
2045
2081
|
return div;
|
|
2046
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
|
+
}
|
|
2047
2120
|
// ─── Element / payload helpers (unchanged from annotation.ts) ────────────
|
|
2048
2121
|
function describeElement(el) {
|
|
2049
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,8 +30,8 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@zumer/snapdom": "^2.12.0",
|
|
32
32
|
"rrweb": "2.0.0-alpha.4",
|
|
33
|
-
"@harness-fe/
|
|
34
|
-
"@harness-fe/
|
|
33
|
+
"@harness-fe/protocol": "4.0.0-next.0",
|
|
34
|
+
"@harness-fe/sandbox": "^3.2.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"happy-dom": "^20.9.0",
|
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.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;
|
|
@@ -317,6 +326,7 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
317
326
|
pickerBar.style.display = next === 'picker' ? 'flex' : 'none';
|
|
318
327
|
annotateModal.style.display = next === 'annotate' ? 'flex' : 'none';
|
|
319
328
|
questionPanel.style.display = next === 'question' ? 'flex' : 'none';
|
|
329
|
+
consentPanel.style.display = next === 'consent' ? 'flex' : 'none';
|
|
320
330
|
document.documentElement.style.cursor = next === 'picker' ? 'crosshair' : '';
|
|
321
331
|
fab.dataset.state = (next === 'picker' || next === 'annotate') ? 'active' : 'idle';
|
|
322
332
|
if (next !== 'picker' && next !== 'question' && next !== 'annotate') {
|
|
@@ -345,6 +355,44 @@ export function installOverlay(client: OverlayClient): void {
|
|
|
345
355
|
}
|
|
346
356
|
};
|
|
347
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
|
+
|
|
348
396
|
// ─── Picker handlers ─────────────────────────────────────────────────
|
|
349
397
|
const setHighlight = (el: Element | null) => {
|
|
350
398
|
if (!el || !(el instanceof HTMLElement || el instanceof SVGElement)) {
|
|
@@ -2182,6 +2230,44 @@ function buildHighlight(): HTMLDivElement {
|
|
|
2182
2230
|
return div;
|
|
2183
2231
|
}
|
|
2184
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
|
+
|
|
2185
2271
|
// ─── Element / payload helpers (unchanged from annotation.ts) ────────────
|
|
2186
2272
|
|
|
2187
2273
|
function describeElement(el: Element): string {
|