@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 CHANGED
@@ -65,7 +65,7 @@ export declare class CaptureStore {
65
65
  }>;
66
66
  readonly storage: RingBuffer<{
67
67
  op: "set" | "remove" | "clear";
68
- which: "local" | "session" | "cookie";
68
+ which: "session" | "local" | "cookie";
69
69
  ts: number;
70
70
  key?: string | undefined;
71
71
  value?: string | undefined;
package/dist/client.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Started lazily by `auto-start.ts` when the script is imported.
6
6
  */
7
- import { COMMAND } from '@harness-fe/protocol';
7
+ import { COMMAND, type ConsentDecision, type ConsentRequest } from '@harness-fe/protocol';
8
8
  import type { QueryMethod } from '@harness-fe/protocol';
9
9
  export interface ClientOptions {
10
10
  projectId: string;
@@ -54,6 +54,11 @@ export declare class RuntimeClient {
54
54
  /** WebSocket state: 'connecting' | 'open' | 'closed'. */
55
55
  getConnectionState(): 'connecting' | 'open' | 'closed';
56
56
  private pageLoadSent;
57
+ private consentMode;
58
+ /** Set once the user grants blanket control for this pageload (mode=session). */
59
+ private consentSessionGranted;
60
+ /** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
61
+ private consentPrompter?;
57
62
  private readonly ctx;
58
63
  private readonly recorder;
59
64
  private reconnectAttempts;
@@ -69,8 +74,20 @@ export declare class RuntimeClient {
69
74
  private onClose;
70
75
  private onMessage;
71
76
  private onQueryResponse;
77
+ /**
78
+ * Register the consent prompter (the overlay installs this). When the
79
+ * policy is on and a control command arrives, the client asks this for the
80
+ * user's decision. Without it, gated commands are denied (fail-safe).
81
+ */
82
+ setConsentPrompter(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
72
83
  private onHelloAck;
73
84
  private handleCommand;
85
+ /**
86
+ * Ask the user (via the overlay-registered prompter) to approve a control
87
+ * command. Fail-safe: if no prompter is registered, or it throws, deny —
88
+ * a consent policy that can't ask must not silently allow.
89
+ */
90
+ private requestConsent;
74
91
  sendEvent(name: string, payload: unknown): void;
75
92
  /**
76
93
  * Request/reply RPC to the daemon. Currently used by the in-page
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Started lazily by `auto-start.ts` when the script is imported.
6
6
  */
7
- import { COMMAND, DEFAULT_WS_PORT, EVENT_NAME, frameSchema, } from '@harness-fe/protocol';
7
+ import { ALWAYS_CONFIRM_COMMANDS, COMMAND, DEFAULT_WS_PORT, EVENT_NAME, requiresConsent, frameSchema, } from '@harness-fe/protocol';
8
8
  import { getCaptureStore } from './capture.js';
9
9
  import { commandHandlers } from './commands.js';
10
10
  import { Outbox } from './outbox.js';
@@ -89,6 +89,14 @@ export class RuntimeClient {
89
89
  }
90
90
  }
91
91
  pageLoadSent = false;
92
+ // Browser consent (4.0 · P2). Mode comes from the daemon in hello.ack;
93
+ // default `off` so a daemon that never sends a policy (or loopback solo
94
+ // dev) keeps running control commands without prompting.
95
+ consentMode = 'off';
96
+ /** Set once the user grants blanket control for this pageload (mode=session). */
97
+ consentSessionGranted = false;
98
+ /** Set by the overlay to collect the user's decision. Absent ⇒ fail-safe deny. */
99
+ consentPrompter;
92
100
  ctx = { capture: getCaptureStore() };
93
101
  // Initialized in constructor (parameter property `opts` isn't readable at
94
102
  // class-field-initializer time — field initializers run before parameter
@@ -209,11 +217,21 @@ export class RuntimeClient {
209
217
  pending.reject(new Error(frame.error?.message ?? 'query failed'));
210
218
  }
211
219
  }
220
+ /**
221
+ * Register the consent prompter (the overlay installs this). When the
222
+ * policy is on and a control command arrives, the client asks this for the
223
+ * user's decision. Without it, gated commands are denied (fail-safe).
224
+ */
225
+ setConsentPrompter(fn) {
226
+ this.consentPrompter = fn;
227
+ }
212
228
  onHelloAck(frame) {
213
229
  if (frame.error) {
214
230
  // Bridge rejected this hello — do not send PAGE_LOAD.
215
231
  return;
216
232
  }
233
+ // Adopt the daemon's consent policy for this connection (4.0 · P2).
234
+ this.consentMode = frame.consent?.mode ?? 'off';
217
235
  // Force a fresh rrweb FullSnapshot on every ack — including reconnects
218
236
  // after daemon restart, network blips, or page-recovery from sleep.
219
237
  // Without this, the only baseline for the session is whatever rrweb
@@ -247,6 +265,23 @@ export class RuntimeClient {
247
265
  });
248
266
  return;
249
267
  }
268
+ // Browser-consent gate (4.0 · P2): control commands need the user's
269
+ // OK in the page before they run when the daemon enabled consent.
270
+ if (requiresConsent(frame.command, this.consentMode, this.consentSessionGranted)) {
271
+ const decision = await this.requestConsent(frame);
272
+ if (decision === 'deny') {
273
+ this.send({
274
+ type: 'response',
275
+ id: frame.id,
276
+ ok: false,
277
+ error: { code: 'CONSENT_DENIED', message: `user denied "${frame.command}"` },
278
+ });
279
+ return;
280
+ }
281
+ if (decision === 'session')
282
+ this.consentSessionGranted = true;
283
+ // 'once' → run this one without granting the rest of the session.
284
+ }
250
285
  try {
251
286
  const result = await handler(frame.args ?? {}, this.ctx);
252
287
  this.send({
@@ -266,6 +301,27 @@ export class RuntimeClient {
266
301
  });
267
302
  }
268
303
  }
304
+ /**
305
+ * Ask the user (via the overlay-registered prompter) to approve a control
306
+ * command. Fail-safe: if no prompter is registered, or it throws, deny —
307
+ * a consent policy that can't ask must not silently allow.
308
+ */
309
+ async requestConsent(frame) {
310
+ if (!this.consentPrompter)
311
+ return 'deny';
312
+ const req = {
313
+ command: frame.command,
314
+ args: frame.args,
315
+ tabId: this.tabId,
316
+ alwaysConfirm: ALWAYS_CONFIRM_COMMANDS.has(frame.command),
317
+ };
318
+ try {
319
+ return await this.consentPrompter(req);
320
+ }
321
+ catch {
322
+ return 'deny';
323
+ }
324
+ }
269
325
  sendEvent(name, payload) {
270
326
  const event = {
271
327
  type: 'event',
package/dist/overlay.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * Single Shadow DOM root attached to <body>; host page styles never leak in
14
14
  * or out. State machine: idle → info → (picker → question) → flash → idle.
15
15
  */
16
- import { type TaskAttachment } from '@harness-fe/protocol';
16
+ import { type ConsentDecision, type ConsentRequest, type TaskAttachment } from '@harness-fe/protocol';
17
17
  import { type OverlayPluginLogs, type OverlayPluginGetLogsOptions } from './pluginRegistry.js';
18
18
  export interface OverlayClient {
19
19
  readonly projectId: string;
@@ -37,6 +37,12 @@ export interface OverlayClient {
37
37
  * tasks. Resolves with `result`, rejects with the remote error message.
38
38
  */
39
39
  query?<TResult = unknown>(method: string, args?: unknown): Promise<TResult>;
40
+ /**
41
+ * Register the browser-consent prompter (4.0 · P2). The client calls this
42
+ * to ask the user before running a control command. Optional so embedders
43
+ * with a non-RuntimeClient overlay client still type-check.
44
+ */
45
+ setConsentPrompter?(fn: (req: ConsentRequest) => Promise<ConsentDecision>): void;
40
46
  }
41
47
  export declare function installOverlay(client: OverlayClient): void;
42
48
  interface ArrowStroke {
package/dist/overlay.js CHANGED
@@ -77,8 +77,9 @@ export function installOverlay(client) {
77
77
  const pickerBar = buildPickerBar();
78
78
  const annotateModal = buildAnnotateModal();
79
79
  const questionPanel = buildQuestionPanel();
80
+ const consentPanel = buildConsentPanel();
80
81
  const highlight = buildHighlight();
81
- root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, highlight);
82
+ root.append(fab, infoCard, reportsCard, pickerBar, annotateModal, questionPanel, consentPanel, highlight);
82
83
  const mount = () => {
83
84
  if (!document.body)
84
85
  return false;
@@ -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.4.0",
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/sandbox": "^3.2.0",
34
- "@harness-fe/protocol": "3.2.0"
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 {