@runtypelabs/persona 4.34.0 → 4.35.1

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.
Files changed (153) hide show
  1. package/dist/activity-ui.d.cts +3 -3
  2. package/dist/activity-ui.d.ts +3 -3
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/approval-ui.d.cts +3 -3
  8. package/dist/approval-ui.d.ts +3 -3
  9. package/dist/artifacts-ui.d.cts +3 -3
  10. package/dist/artifacts-ui.d.ts +3 -3
  11. package/dist/background-runtime.d.cts +1 -1
  12. package/dist/background-runtime.d.ts +1 -1
  13. package/dist/{chunk-CxNPSY-b.js → chunk-B1P8w9_X.js} +1 -1
  14. package/dist/{chunk-CV8EPIhT.js → chunk-B30Jdgg6.js} +1 -1
  15. package/dist/{chunk-6CUrH_jm.js → chunk-Bzo9GaAi.js} +1 -1
  16. package/dist/chunk-CNBMBx-Y.js +2 -0
  17. package/dist/chunk-Cd8mp8Sv.js +1 -0
  18. package/dist/chunk-CsfE8WRG.js +1 -0
  19. package/dist/{chunk-Co3VVwGe.js → chunk-D2pHRNEq.js} +0 -0
  20. package/dist/chunk-D3kZO8u-.js +1 -0
  21. package/dist/chunk-DAthf5H3.js +1 -0
  22. package/dist/{chunk-YMkMvAsa.js → chunk-DZ_aillY.js} +1 -1
  23. package/dist/{chunk-6C23QQdP.js → chunk-DgJV9zbK.js} +1 -1
  24. package/dist/chunk-Dv6KtIT1.js +1 -0
  25. package/dist/chunk-Dws8IrdR.js +1 -0
  26. package/dist/{chunk-DsWv3EGF.js → chunk-ThxxD8oK.js} +1 -1
  27. package/dist/client-dispatch.js +1 -1
  28. package/dist/client-history.js +1 -1
  29. package/dist/codegen.cjs +1 -1
  30. package/dist/codegen.js +1 -1
  31. package/dist/composer-actions-runtime.cjs +1 -1
  32. package/dist/composer-actions-runtime.d.cts +3 -3
  33. package/dist/composer-actions-runtime.d.ts +3 -3
  34. package/dist/composer-actions-runtime.js +1 -1
  35. package/dist/{composer-input-i_FiUp6J.d.cts → composer-input-BSDNPOqV.d.cts} +1 -1
  36. package/dist/{composer-input-i_FiUp6J.d.ts → composer-input-BSDNPOqV.d.ts} +1 -1
  37. package/dist/{composer-store-DhGNgG7Z.d.cts → composer-store-DC-gAjtN.d.cts} +1 -1
  38. package/dist/{composer-store-DhGNgG7Z.d.ts → composer-store-DC-gAjtN.d.ts} +1 -1
  39. package/dist/{context-mention-manager-DL-xwGfL.d.cts → context-mention-manager-CCpeoGL2.d.cts} +2 -2
  40. package/dist/{context-mention-manager-DL-xwGfL.d.ts → context-mention-manager-CCpeoGL2.d.ts} +2 -2
  41. package/dist/context-mentions-inline.d.cts +2 -2
  42. package/dist/context-mentions-inline.d.ts +2 -2
  43. package/dist/context-mentions.cjs +1 -1
  44. package/dist/context-mentions.d.cts +3 -3
  45. package/dist/context-mentions.d.ts +3 -3
  46. package/dist/context-mentions.js +1 -1
  47. package/dist/{defaults-Bga2Xrsp.d.cts → defaults-tj5RcN8r.d.cts} +1 -1
  48. package/dist/{defaults-Bga2Xrsp.d.ts → defaults-tj5RcN8r.d.ts} +1 -1
  49. package/dist/dictation-runtime.d.cts +2 -2
  50. package/dist/dictation-runtime.d.ts +2 -2
  51. package/dist/event-stream-view.d.cts +1 -1
  52. package/dist/event-stream-view.d.ts +1 -1
  53. package/dist/{fallback-speech-engine-kLdyPcmk.d.cts → fallback-speech-engine-gkhbjLiZ.d.cts} +1 -1
  54. package/dist/{fallback-speech-engine-kLdyPcmk.d.ts → fallback-speech-engine-gkhbjLiZ.d.ts} +1 -1
  55. package/dist/{feature-preferences-CmwZGKrf.d.cts → feature-preferences-CerDWGZ7.d.cts} +1 -1
  56. package/dist/{feature-preferences-CmwZGKrf.d.ts → feature-preferences-CerDWGZ7.d.ts} +1 -1
  57. package/dist/forms-ui.d.cts +2 -2
  58. package/dist/forms-ui.d.ts +2 -2
  59. package/dist/{history-confirm-dialog-i46-lPN_.d.cts → history-confirm-dialog-CrkSB3Ra.d.cts} +1 -1
  60. package/dist/{history-confirm-dialog-i46-lPN_.d.ts → history-confirm-dialog-CrkSB3Ra.d.ts} +1 -1
  61. package/dist/history-shell.d.cts +6 -6
  62. package/dist/history-shell.d.ts +6 -6
  63. package/dist/history-view.d.cts +1 -1
  64. package/dist/history-view.d.ts +1 -1
  65. package/dist/index.cjs +32 -32
  66. package/dist/index.cjs.map +1 -1
  67. package/dist/index.d.cts +8 -8
  68. package/dist/index.d.ts +8 -8
  69. package/dist/index.global.js +1 -1
  70. package/dist/index.global.js.map +1 -1
  71. package/dist/index.js +40 -40
  72. package/dist/index.js.map +1 -1
  73. package/dist/install.global.js +1 -1
  74. package/dist/launcher.global.js +1 -1
  75. package/dist/{loading-label-runtime-loader-Ccr3ETd3.d.cts → loading-label-runtime-loader-fWIRrhUF.d.cts} +1 -1
  76. package/dist/{loading-label-runtime-loader-Ccr3ETd3.d.ts → loading-label-runtime-loader-fWIRrhUF.d.ts} +1 -1
  77. package/dist/loading-label-runtime.d.cts +1 -1
  78. package/dist/loading-label-runtime.d.ts +1 -1
  79. package/dist/{message-bubble-C6AVaH7f.d.cts → message-bubble-Czc0lOHr.d.cts} +1 -1
  80. package/dist/{message-bubble-C6AVaH7f.d.ts → message-bubble-Czc0lOHr.d.ts} +1 -1
  81. package/dist/{plugin-kit-BVggqE3C.d.cts → plugin-kit-C30jE-cw.d.cts} +3 -1
  82. package/dist/{plugin-kit-BVggqE3C.d.ts → plugin-kit-C30jE-cw.d.ts} +3 -1
  83. package/dist/plugin-kit.cjs +1 -1
  84. package/dist/plugin-kit.d.cts +1 -1
  85. package/dist/plugin-kit.d.ts +1 -1
  86. package/dist/plugin-kit.js +1 -1
  87. package/dist/preferences-runtime.d.cts +1 -1
  88. package/dist/preferences-runtime.d.ts +1 -1
  89. package/dist/{preview-BMdRffo4.d.cts → preview-D-osV3FU.d.cts} +2 -2
  90. package/dist/{preview-BMdRffo4.d.ts → preview-D-osV3FU.d.ts} +2 -2
  91. package/dist/{registry-B16l8s1b.d.cts → registry-B6w91tx0.d.cts} +1 -1
  92. package/dist/{registry-B16l8s1b.d.ts → registry-B6w91tx0.d.ts} +1 -1
  93. package/dist/{session-CtZHe7fc.d.cts → session-BUQrs3Of.d.cts} +10 -9
  94. package/dist/{session-CtZHe7fc.d.ts → session-BUQrs3Of.d.ts} +10 -9
  95. package/dist/session-reconnect.d.cts +2 -2
  96. package/dist/session-reconnect.d.ts +2 -2
  97. package/dist/smart-dom-reader.d.cts +1 -1
  98. package/dist/smart-dom-reader.d.ts +1 -1
  99. package/dist/theme-editor-preview.cjs +43 -43
  100. package/dist/theme-editor-preview.d.cts +1 -1
  101. package/dist/theme-editor-preview.d.ts +1 -1
  102. package/dist/theme-editor-preview.js +3 -3
  103. package/dist/theme-editor.d.cts +2 -2
  104. package/dist/theme-editor.d.ts +2 -2
  105. package/dist/theme-reference.d.cts +2 -2
  106. package/dist/theme-reference.d.ts +2 -2
  107. package/dist/{tool-loading-animation-D2cyFvmM.d.cts → tool-loading-animation-B7XUf8TN.d.cts} +1 -1
  108. package/dist/{tool-loading-animation-D2cyFvmM.d.ts → tool-loading-animation-B7XUf8TN.d.ts} +1 -1
  109. package/dist/{turn-activity-ui-Do1Nd-Ow.d.cts → turn-activity-ui-CohuNMHT.d.cts} +2 -2
  110. package/dist/{turn-activity-ui-Do1Nd-Ow.d.ts → turn-activity-ui-CohuNMHT.d.ts} +2 -2
  111. package/dist/turn-activity-ui.d.cts +1 -1
  112. package/dist/turn-activity-ui.d.ts +1 -1
  113. package/dist/{types-BC7MRxO5.d.cts → types-9i0xcAMT.d.cts} +52 -0
  114. package/dist/{types-BC7MRxO5.d.ts → types-9i0xcAMT.d.ts} +52 -0
  115. package/dist/voice-client-tools.d.cts +1 -1
  116. package/dist/voice-client-tools.d.ts +1 -1
  117. package/dist/voice-runtime.cjs +1 -1
  118. package/dist/voice-runtime.d.cts +2 -2
  119. package/dist/voice-runtime.d.ts +2 -2
  120. package/dist/voice-runtime.js +1 -1
  121. package/dist/voice-worklet-player.d.cts +2 -2
  122. package/dist/voice-worklet-player.d.ts +2 -2
  123. package/dist/{webmcp-runtime-entry-B8g46vS0.d.ts → webmcp-runtime-entry-XNaXa-9h.d.cts} +9 -1
  124. package/dist/{webmcp-runtime-entry-B8g46vS0.d.cts → webmcp-runtime-entry-XNaXa-9h.d.ts} +9 -1
  125. package/dist/webmcp-runtime.cjs +1 -1
  126. package/dist/webmcp-runtime.d.cts +1 -1
  127. package/dist/webmcp-runtime.d.ts +1 -1
  128. package/dist/webmcp-runtime.js +1 -1
  129. package/package.json +2 -2
  130. package/src/client-dispatch.ts +13 -0
  131. package/src/client.ts +37 -2
  132. package/src/client.webmcp-require-confirm.test.ts +212 -0
  133. package/src/components/composer-model-picker.ts +0 -14
  134. package/src/components/composer-overflow-menu.ts +0 -15
  135. package/src/components/composer-tokens.ts +0 -20
  136. package/src/generated/runtype-openapi-contract.ts +13 -0
  137. package/src/plugin-kit.test.ts +101 -0
  138. package/src/plugin-kit.ts +29 -1
  139. package/src/session.ts +6 -2
  140. package/src/session.webmcp.test.ts +32 -0
  141. package/src/types.ts +35 -0
  142. package/src/ui.composer-models.test.ts +5 -7
  143. package/src/ui.composer-overflow.test.ts +14 -21
  144. package/src/utils/context-mention-controller.test.ts +56 -0
  145. package/src/webmcp-bridge.test.ts +102 -0
  146. package/src/webmcp-runtime-entry.ts +29 -1
  147. package/dist/chunk-BAkaTZpd.js +0 -1
  148. package/dist/chunk-BEDTp-dm.js +0 -1
  149. package/dist/chunk-C-rux8Q9.js +0 -1
  150. package/dist/chunk-CNhThrLX.js +0 -1
  151. package/dist/chunk-CzLEhoB_.js +0 -2
  152. package/dist/chunk-Dqa_GOcj.js +0 -1
  153. package/dist/chunk-NUNdcnAZ.js +0 -1
@@ -262,6 +262,107 @@ describe("createPopover", () => {
262
262
  });
263
263
  });
264
264
 
265
+ describe("createPopover theme carry-over", () => {
266
+ const setupRoot = () => {
267
+ const root = document.createElement("div");
268
+ root.setAttribute("data-persona-root", "true");
269
+ root.setAttribute("data-persona-color-scheme", "dark");
270
+ root.setAttribute("data-persona-defaults", "v5");
271
+ root.style.setProperty("--persona-surface", "#17181c");
272
+ root.style.setProperty("--persona-border", "#2e3038");
273
+ root.style.setProperty("--persona-text", "#f4f4f5");
274
+ root.style.height = "100%";
275
+ const anchor = document.createElement("button");
276
+ root.appendChild(anchor);
277
+ document.body.appendChild(root);
278
+ const content = document.createElement("div");
279
+ return { root, anchor, content };
280
+ };
281
+
282
+ it("copies the root's theme variables and scheme onto body-mounted content", () => {
283
+ const { anchor, content } = setupRoot();
284
+ createPopover({ anchor, content }).open();
285
+
286
+ expect(content.parentElement).toBe(document.body);
287
+ expect(content.style.getPropertyValue("--persona-surface")).toBe("#17181c");
288
+ expect(content.style.getPropertyValue("--persona-border")).toBe("#2e3038");
289
+ expect(content.style.getPropertyValue("--persona-text")).toBe("#f4f4f5");
290
+ expect(content.getAttribute("data-persona-color-scheme")).toBe("dark");
291
+ expect(content.getAttribute("data-persona-defaults")).toBe("v5");
292
+ // Only custom properties travel, never the root's own layout.
293
+ expect(content.style.height).toBe("");
294
+ // Not a second widget root: root-scoped rules and lookups stay unambiguous.
295
+ expect(content.hasAttribute("data-persona-root")).toBe(false);
296
+ });
297
+
298
+ it("follows a live scheme change while open and clears removed variables", async () => {
299
+ const { root, anchor, content } = setupRoot();
300
+ createPopover({ anchor, content }).open();
301
+
302
+ root.style.setProperty("--persona-surface", "#ffffff");
303
+ root.style.removeProperty("--persona-border");
304
+ root.setAttribute("data-persona-color-scheme", "light");
305
+ await flushMicrotasks();
306
+
307
+ expect(content.style.getPropertyValue("--persona-surface")).toBe("#ffffff");
308
+ expect(content.style.getPropertyValue("--persona-border")).toBe("");
309
+ expect(content.getAttribute("data-persona-color-scheme")).toBe("light");
310
+ });
311
+
312
+ it("stops following the root once closed and refreshes on the next open", async () => {
313
+ const { root, anchor, content } = setupRoot();
314
+ const popover = createPopover({ anchor, content });
315
+ popover.open();
316
+ popover.close();
317
+
318
+ root.style.setProperty("--persona-surface", "#ffffff");
319
+ await flushMicrotasks();
320
+ expect(content.style.getPropertyValue("--persona-surface")).toBe("#17181c");
321
+
322
+ popover.open();
323
+ expect(content.style.getPropertyValue("--persona-surface")).toBe("#ffffff");
324
+ });
325
+
326
+ it("leaves content mounted inside the root to plain inheritance", () => {
327
+ const { root, anchor, content } = setupRoot();
328
+ createPopover({ anchor, content, container: root }).open();
329
+
330
+ expect(content.parentElement).toBe(root);
331
+ expect(content.style.getPropertyValue("--persona-surface")).toBe("");
332
+ expect(content.hasAttribute("data-persona-color-scheme")).toBe(false);
333
+ });
334
+
335
+ it("carries the theme into a shadow root, where content sits beside the root", () => {
336
+ const host = document.createElement("div");
337
+ document.body.appendChild(host);
338
+ const shadow = host.attachShadow({ mode: "open" });
339
+ const root = document.createElement("div");
340
+ root.setAttribute("data-persona-root", "true");
341
+ root.setAttribute("data-persona-color-scheme", "dark");
342
+ root.style.setProperty("--persona-surface", "#17181c");
343
+ const anchor = document.createElement("button");
344
+ root.appendChild(anchor);
345
+ shadow.appendChild(root);
346
+ const content = document.createElement("div");
347
+
348
+ createPopover({ anchor, content }).open();
349
+
350
+ expect(content.parentNode).toBe(shadow);
351
+ expect(content.style.getPropertyValue("--persona-surface")).toBe("#17181c");
352
+ expect(content.getAttribute("data-persona-color-scheme")).toBe("dark");
353
+ });
354
+
355
+ it("does nothing for an anchor outside any widget root", () => {
356
+ const anchor = document.createElement("button");
357
+ document.body.appendChild(anchor);
358
+ const content = document.createElement("div");
359
+ createPopover({ anchor, content }).open();
360
+
361
+ expect(content.style.cssText).not.toContain("--persona");
362
+ expect(content.hasAttribute("data-persona-color-scheme")).toBe(false);
363
+ });
364
+ });
365
+
265
366
  describe("viewport-constrained popovers", () => {
266
367
  const rect = (left: number, top: number, width: number, height: number): DOMRect =>
267
368
  ({ left, top, right: left + width, bottom: top + height, width, height }) as DOMRect;
package/src/plugin-kit.ts CHANGED
@@ -156,7 +156,9 @@ export interface PopoverOptions {
156
156
  /**
157
157
  * Where to mount `content`. Defaults to the anchor's shadow root (when
158
158
  * shadowed), its open dialog, or `document.body`: keeping it inside the same style + stacking
159
- * scope as the anchor while escaping the transcript's scroll clipping.
159
+ * scope as the anchor while escaping the transcript's scroll clipping. Mounted
160
+ * outside the anchor's `[data-persona-root]`, `content` carries a copy of the
161
+ * root's theme variables and color scheme, kept current while open.
160
162
  */
161
163
  container?: HTMLElement | ShadowRoot;
162
164
  onOpen?: () => void;
@@ -216,6 +218,24 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
216
218
  const currentContainer = () => options.container ?? defaultContainer(anchor);
217
219
  let open = false;
218
220
  let detach: (() => void) | null = null;
221
+ let carried: string[] = [];
222
+
223
+ // The theme lives on the widget root as inline custom properties plus scheme
224
+ // attributes. Content mounted outside that root (body, dialog, shadow root)
225
+ // would paint the stylesheet's light fallbacks, so it carries a copy.
226
+ const carryTheme = (): void => {
227
+ const root = anchor.closest<HTMLElement>("[data-persona-root]");
228
+ if (!root || root.contains(content)) return;
229
+ const { style } = root;
230
+ for (const name of carried) if (!style.getPropertyValue(name)) content.style.removeProperty(name);
231
+ carried = Array.from(style).filter((name) => name.startsWith("--"));
232
+ for (const name of carried) content.style.setProperty(name, style.getPropertyValue(name));
233
+ for (const attr of ["data-persona-color-scheme", "data-persona-defaults"]) {
234
+ const value = root.getAttribute(attr);
235
+ if (value) content.setAttribute(attr, value);
236
+ else content.removeAttribute(attr);
237
+ }
238
+ };
219
239
 
220
240
  const reposition = (): void => {
221
241
  if (!open) return;
@@ -224,6 +244,7 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
224
244
  const focused = content.contains(content.ownerDocument.activeElement)
225
245
  ? content.ownerDocument.activeElement as HTMLElement : null;
226
246
  container.appendChild(content);
247
+ carryTheme();
227
248
  focused?.focus({ preventScroll: true });
228
249
  }
229
250
  const rect = options.getAnchorRect?.() ?? anchor.getBoundingClientRect();
@@ -321,9 +342,16 @@ export function createPopover(options: PopoverOptions): PopoverHandle {
321
342
  ownerWindow.addEventListener("resize", onReposition);
322
343
  const moveRoot = anchor.closest("[data-persona-root]");
323
344
  moveRoot?.addEventListener("persona:internal:host-moved", onReposition);
345
+ // A live theme or color-scheme change rewrites the root while open.
346
+ let themeObserver: MutationObserver | undefined;
347
+ if (moveRoot && ownerWindow.MutationObserver) {
348
+ themeObserver = new ownerWindow.MutationObserver(carryTheme);
349
+ themeObserver.observe(moveRoot, { attributeFilter: ["style", "data-persona-color-scheme"] });
350
+ }
324
351
 
325
352
  detach = () => {
326
353
  observer?.disconnect();
354
+ themeObserver?.disconnect();
327
355
  ownerWindow.clearTimeout(armId);
328
356
  ownerDocument.removeEventListener("pointerdown", onOutside, true);
329
357
  ownerWindow.removeEventListener("scroll", onReposition, true);
package/src/session.ts CHANGED
@@ -3305,9 +3305,13 @@ export class AgentWidgetSession {
3305
3305
  public requestWebMcpApproval(info: WebMcpConfirmInfo): Promise<boolean> {
3306
3306
  // Per-tool policy hook: auto-allow opted-out tools without any UI. A
3307
3307
  // throwing predicate must not block the call, so fall through to an
3308
- // explicit gate on error.
3308
+ // explicit gate on error. A `requireConfirmFor` match (flagged by the
3309
+ // bridge) never consults it: the surface owner said it must be confirmed.
3309
3310
  try {
3310
- if (this.config.webmcp?.autoApprove?.(info) === true) {
3311
+ if (
3312
+ info.confirmRequired !== true &&
3313
+ this.config.webmcp?.autoApprove?.(info) === true
3314
+ ) {
3311
3315
  return Promise.resolve(true);
3312
3316
  }
3313
3317
  } catch {
@@ -1230,3 +1230,35 @@ describe("AgentWidgetSession: WebMCP parallel batched resume (core#3878)", () =>
1230
1230
  expect(s.webMcpApprovalResolvers.size).toBe(0);
1231
1231
  });
1232
1232
  });
1233
+
1234
+ describe("WebMCP requireConfirmFor on the default approval UI", () => {
1235
+ const pendingBubbles = (session: AgentWidgetSession) =>
1236
+ (session as unknown as { messages: AgentWidgetMessage[] }).messages.filter(
1237
+ (m) => m.variant === "approval" && m.approval?.status === "pending",
1238
+ );
1239
+
1240
+ it("prompts for a matched tool even when autoApprove returns true", async () => {
1241
+ const autoApprove = vi.fn(() => true);
1242
+ const { session } = makeSession({ webmcp: { autoApprove } });
1243
+ const pending = session.requestWebMcpApproval({
1244
+ toolName: "checkout_submit",
1245
+ args: {},
1246
+ reason: "gate",
1247
+ confirmRequired: true,
1248
+ });
1249
+ expect(autoApprove).not.toHaveBeenCalled();
1250
+ expect(pendingBubbles(session)).toHaveLength(1);
1251
+ session.cancel();
1252
+ await expect(pending).resolves.toBe(false);
1253
+ });
1254
+
1255
+ it("still auto-approves an unmatched tool", async () => {
1256
+ const autoApprove = vi.fn(() => true);
1257
+ const { session } = makeSession({ webmcp: { autoApprove } });
1258
+ await expect(
1259
+ session.requestWebMcpApproval({ toolName: "search", args: {}, reason: "gate" }),
1260
+ ).resolves.toBe(true);
1261
+ expect(autoApprove).toHaveBeenCalledTimes(1);
1262
+ expect(pendingBubbles(session)).toHaveLength(0);
1263
+ });
1264
+ });
package/src/types.ts CHANGED
@@ -886,6 +886,16 @@ export type WebMcpConfirmInfo = {
886
886
  * callbacks on v6 receive an AbortSignal, not a nested confirmation API.
887
887
  */
888
888
  reason: 'gate';
889
+ /**
890
+ * `true` when the tool matches a `requireConfirmFor` pattern (the widget's
891
+ * `webmcp.requireConfirmFor`, or the bound Runtype surface's
892
+ * `behavior.webmcp.requireConfirmFor` from client-token init). The call
893
+ * must be confirmed by the user: Persona never runs `autoApprove` for it,
894
+ * and a custom `onConfirm` should not auto-approve it either. In
895
+ * client-token mode it is also set on every call until the surface policy
896
+ * has loaded (fail closed).
897
+ */
898
+ confirmRequired?: true;
889
899
  /**
890
900
  * Aborts when the call no longer needs an answer (its deadline passed, or it
891
901
  * was cancelled). A confirm UI should close and resolve `false`; the tool
@@ -936,8 +946,23 @@ export type AgentWidgetWebMcpConfig = {
936
946
  * confirmation UI entirely. Use this to auto-allow read-only tools (e.g.
937
947
  * a catalog search) while still gating mutating ones. Only consulted on
938
948
  * the default-UI path: a custom `onConfirm` takes full control instead.
949
+ * Never consulted for a tool matching `requireConfirmFor`.
939
950
  */
940
951
  autoApprove?: (info: WebMcpConfirmInfo) => boolean;
952
+ /**
953
+ * Tool-name patterns that must always be confirmed, even when `autoApprove`
954
+ * would approve them (and even on the voice path's read-only fast tier).
955
+ * Matched against the bare tool name (no `webmcp:` prefix); `*` matches any
956
+ * run of characters, e.g. `["checkout_*", "delete_account"]`. A matched
957
+ * call shows the default approval bubble, or reaches your `onConfirm` with
958
+ * `info.confirmRequired === true`.
959
+ *
960
+ * Merged with the bound surface's `behavior.webmcp.requireConfirmFor`,
961
+ * which Runtype returns from `/v1/client/init` in client-token mode. This
962
+ * is client-side UX, not a security boundary: the server enforces only
963
+ * `webmcp.enabled` and its `allowlist`.
964
+ */
965
+ requireConfirmFor?: string[];
941
966
  /**
942
967
  * Confirm gate handler. When omitted, Persona renders its native in-panel
943
968
  * approval bubble (the same chrome used for server-driven tool approvals)
@@ -6822,6 +6847,11 @@ export type ClientSession = {
6822
6847
  welcomeMessage: string | null;
6823
6848
  placeholder: string;
6824
6849
  theme: Record<string, unknown> | null;
6850
+ /**
6851
+ * The bound chat surface's WebMCP confirm policy. Present only when the
6852
+ * surface enables WebMCP and lists `requireConfirmFor` patterns.
6853
+ */
6854
+ webmcp?: { requireConfirmFor: string[] };
6825
6855
  };
6826
6856
  };
6827
6857
 
@@ -6862,6 +6892,11 @@ export type ClientInitResponse = {
6862
6892
  welcomeMessage: string | null;
6863
6893
  placeholder: string;
6864
6894
  theme: Record<string, unknown> | null;
6895
+ /**
6896
+ * The bound chat surface's WebMCP confirm policy. Present only when the
6897
+ * surface enables WebMCP and lists `requireConfirmFor` patterns.
6898
+ */
6899
+ webmcp?: { requireConfirmFor: string[] };
6865
6900
  };
6866
6901
  };
6867
6902
 
@@ -344,29 +344,27 @@ describe("composer model picker", () => {
344
344
  },
345
345
  });
346
346
  expect(mount.style.getPropertyValue(token)).toBe("#1e1f20");
347
- // The panel is portaled to document.body, so it never inherits the
348
- // mount's theme vars. Stamped on the trigger here because jsdom's
349
- // computed style does not inherit custom properties.
347
+ // The panel is portaled to document.body, outside the mount, so it
348
+ // carries a copy of the mount's theme vars.
350
349
  const button = trigger(mount)!;
351
- button.style.setProperty(token, "#1e1f20");
352
350
 
353
351
  button.click();
354
352
  const panel = menu()!;
353
+ expect(mount.contains(panel)).toBe(false);
355
354
  expect(panel.style.getPropertyValue(token)).toBe("#1e1f20");
356
355
  button.click();
357
356
 
358
357
  // Explicit undefined is the documented reset, so the mount stops
359
- // carrying the var; the trigger's stand-in follows it.
358
+ // carrying the var.
360
359
  controller.update({
361
360
  theme: {
362
361
  components: { composer: { modelPicker: { menuBackground: undefined } } },
363
362
  },
364
363
  });
365
364
  expect(mount.style.getPropertyValue(token)).toBe("");
366
- button.style.removeProperty(token);
367
365
 
368
366
  // The menu outlives the update, so the next open has to clear the value
369
- // the first one stamped; an empty inline value is the fallback state.
367
+ // the first one copied; an empty inline value is the fallback state.
370
368
  trigger(mount)!.click();
371
369
  expect(menu()).toBe(panel);
372
370
  expect(panel.style.getPropertyValue(token)).toBe("");
@@ -103,27 +103,25 @@ describe("composer overflow menu in the live widget", () => {
103
103
  vi.restoreAllMocks();
104
104
  });
105
105
 
106
- it("forwards the panel surface tokens onto the portaled menu on open", () => {
106
+ it("carries the panel surface tokens onto the portaled menu on open", () => {
107
107
  const { mount } = makeController({
108
108
  composer: {
109
109
  actions: [action("folded", { presentation: "overflow" })],
110
110
  actionOverflow: { enabled: true },
111
111
  },
112
+ theme: {
113
+ components: {
114
+ composer: {
115
+ overflowMenu: { background: "#353535", borderColor: "rgba(255, 255, 255, 0.08)" },
116
+ },
117
+ },
118
+ },
112
119
  });
113
- // The panel is portaled to document.body, so it never inherits the mount's
114
- // theme vars. Stamped on the trigger here because jsdom's computed style
115
- // does not inherit custom properties.
116
- const trigger = triggerOf(mount)!;
117
- trigger.style.setProperty(
118
- "--persona-components-composer-overflowMenu-background",
119
- "#353535"
120
- );
121
- trigger.style.setProperty(
122
- "--persona-components-composer-overflowMenu-borderColor",
123
- "rgba(255, 255, 255, 0.08)"
124
- );
125
120
 
121
+ // The panel is portaled to document.body, outside the mount, so it
122
+ // carries a copy of the mount's theme vars.
126
123
  const panel = openPanel(mount)!;
124
+ expect(mount.contains(panel)).toBe(false);
127
125
 
128
126
  expect(
129
127
  panel.style.getPropertyValue(
@@ -135,7 +133,7 @@ describe("composer overflow menu in the live widget", () => {
135
133
  "--persona-components-composer-overflowMenu-borderColor"
136
134
  )
137
135
  ).toBe("rgba(255, 255, 255, 0.08)");
138
- // Unset keys stamp nothing, so the stylesheet's fallback still stands.
136
+ // Unset keys copy nothing, so the stylesheet's fallback still stands.
139
137
  expect(
140
138
  panel.style.getPropertyValue(
141
139
  "--persona-components-composer-overflowMenu-shadow"
@@ -155,27 +153,22 @@ describe("composer overflow menu in the live widget", () => {
155
153
  },
156
154
  });
157
155
  expect(mount.style.getPropertyValue(token)).toBe("#353535");
158
- // Stamped on the trigger for the same reason as the test above: jsdom's
159
- // computed style does not inherit custom properties.
160
- const trigger = triggerOf(mount)!;
161
- trigger.style.setProperty(token, "#353535");
162
156
 
163
157
  const panel = openPanel(mount)!;
164
158
  expect(panel.style.getPropertyValue(token)).toBe("#353535");
165
159
  triggerOf(mount)!.click();
166
160
 
167
161
  // Explicit undefined is the documented reset, so the mount stops carrying
168
- // the var; the trigger's stand-in follows it.
162
+ // the var.
169
163
  controller.update({
170
164
  theme: {
171
165
  components: { composer: { overflowMenu: { background: undefined } } },
172
166
  },
173
167
  });
174
168
  expect(mount.style.getPropertyValue(token)).toBe("");
175
- trigger.style.removeProperty(token);
176
169
 
177
170
  // The panel outlives the update, so the next open has to clear the value
178
- // the first one stamped; an empty inline value is the fallback state.
171
+ // the first one copied; an empty inline value is the fallback state.
179
172
  const reopened = openPanel(mount)!;
180
173
  expect(reopened).toBe(panel);
181
174
  expect(reopened.style.getPropertyValue(token)).toBe("");
@@ -5,6 +5,7 @@ import { ContextMentionController } from "./context-mention-controller";
5
5
  import { createTextareaComposerInput } from "./composer-input";
6
6
  import { createContentEditableComposerInput } from "./composer-contenteditable";
7
7
  import { createSlashCommandsSource } from "./mention-matcher";
8
+ import { applyThemeVariables } from "./theme";
8
9
  import type {
9
10
  AgentWidgetConfig,
10
11
  AgentWidgetContextMentionConfig,
@@ -433,6 +434,61 @@ describe("ContextMentionController", () => {
433
434
  });
434
435
  });
435
436
 
437
+ describe("ContextMentionController — theme", () => {
438
+ beforeEach(() => {
439
+ document.body.innerHTML = "";
440
+ });
441
+
442
+ // The menu portals to document.body, outside the themed widget root. Without
443
+ // the root's variables it painted the light fallbacks in a dark widget.
444
+ it("paints the body-portaled menu with the widget's dark theme and follows a scheme change", async () => {
445
+ const root = document.createElement("div");
446
+ root.setAttribute("data-persona-root", "true");
447
+ const dark: AgentWidgetConfig = {
448
+ colorScheme: "dark",
449
+ future: { v5Defaults: true },
450
+ theme: { semantic: { colors: { surface: "#17181c", border: "#2e3038" } } },
451
+ } as AgentWidgetConfig;
452
+ applyThemeVariables(root, dark);
453
+ const form = document.createElement("form");
454
+ const textarea = document.createElement("textarea");
455
+ form.appendChild(textarea);
456
+ root.appendChild(form);
457
+ document.body.appendChild(root);
458
+
459
+ const controller = new ContextMentionController({
460
+ mentionConfig: { enabled: true, sources: [syncSource("files", [item("App.tsx")])] },
461
+ composerInput: createTextareaComposerInput(textarea),
462
+ anchor: form,
463
+ getMessages: () => [],
464
+ getConfig: () => ({}) as AgentWidgetConfig,
465
+ onSelect: vi.fn(() => true),
466
+ announce: vi.fn(),
467
+ });
468
+ textarea.value = "@";
469
+ textarea.setSelectionRange(1, 1);
470
+ controller.onInput();
471
+
472
+ const menu = document.querySelector<HTMLElement>("[data-persona-mention-menu]")!;
473
+ expect(menu.parentElement).toBe(document.body);
474
+ expect(menu.getAttribute("data-persona-color-scheme")).toBe("dark");
475
+ expect(menu.style.getPropertyValue("--persona-surface")).toBe("#17181c");
476
+ expect(menu.style.getPropertyValue("--persona-border")).toBe("#2e3038");
477
+ const darkText = root.style.getPropertyValue("--persona-text");
478
+ expect(darkText).not.toBe("");
479
+ expect(menu.style.getPropertyValue("--persona-text")).toBe(darkText);
480
+
481
+ // controller.update({ colorScheme }) re-applies the theme onto the root.
482
+ applyThemeVariables(root, { ...dark, colorScheme: "light" } as AgentWidgetConfig);
483
+ await tick();
484
+ expect(menu.getAttribute("data-persona-color-scheme")).toBe("light");
485
+ expect(menu.style.getPropertyValue("--persona-text")).toBe(root.style.getPropertyValue("--persona-text"));
486
+ expect(menu.style.getPropertyValue("--persona-text")).not.toBe(darkText);
487
+
488
+ controller.destroy();
489
+ });
490
+ });
491
+
436
492
  describe("ContextMentionController — slash-commands", () => {
437
493
  beforeEach(() => {
438
494
  document.body.innerHTML = "";
@@ -30,6 +30,7 @@ vi.mock("@mcp-b/webmcp-polyfill", () => ({
30
30
  // one-argument constructor, wired to the real core deps — so these tests also
31
31
  // exercise the deps-injection seam the lazy CDN path uses.
32
32
  import { WebMcpBridge } from "./webmcp-bridge-public";
33
+ import { WebMcpBridge as WebMcpBridgeRuntime } from "./webmcp-runtime-entry";
33
34
  import {
34
35
  getWebMcpToolDisplayTitle,
35
36
  isWebMcpToolName,
@@ -962,3 +963,104 @@ describe("WebMcpBridge.watchToolChanges", () => {
962
963
  expect(() => stop()).not.toThrow();
963
964
  });
964
965
  });
966
+
967
+ describe("WebMcpBridge requireConfirmFor", () => {
968
+ const runtimeBridge = (
969
+ config: AgentWidgetWebMcpConfig,
970
+ getRequireConfirmFor: () => readonly string[] | null | undefined,
971
+ ) =>
972
+ new WebMcpBridgeRuntime(config, {
973
+ recordToolDisplayTitles: () => {},
974
+ getToolDisplayTitle: () => undefined,
975
+ loadPolyfill: async () => ({ initializeWebMCPPolyfill: () => {} }),
976
+ getRequireConfirmFor,
977
+ });
978
+
979
+ it("flags a widget-config match with confirmRequired on the confirm info", async () => {
980
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
981
+ registry.tools = [fakeTool({ name: "checkout_submit" }), fakeTool({ name: "search" })];
982
+ const bridge = new WebMcpBridge({
983
+ enabled: true,
984
+ onConfirm,
985
+ requireConfirmFor: ["checkout_*"],
986
+ });
987
+ await bridge.executeToolCall("webmcp:checkout_submit", {});
988
+ await bridge.executeToolCall("webmcp:search", {});
989
+ expect(onConfirm).toHaveBeenNthCalledWith(
990
+ 1,
991
+ expect.objectContaining({ toolName: "checkout_submit", confirmRequired: true }),
992
+ );
993
+ expect(onConfirm.mock.calls[1]?.[0]).not.toHaveProperty("confirmRequired");
994
+ });
995
+
996
+ it("reads server patterns at call time and matches the bare tool name", async () => {
997
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
998
+ registry.tools = [fakeTool({ name: "delete_account" }), fakeTool({ name: "deleted_items" })];
999
+ let server: string[] | undefined;
1000
+ const bridge = runtimeBridge({ enabled: true, onConfirm }, () => server);
1001
+ await bridge.executeToolCall("webmcp:delete_account", {});
1002
+ expect(onConfirm.mock.calls[0]?.[0]).not.toHaveProperty("confirmRequired");
1003
+
1004
+ // Init lands after the bridge was built.
1005
+ server = ["delete_account", "*_admin"];
1006
+ await bridge.executeToolCall("webmcp:delete_account", {});
1007
+ await bridge.executeToolCall("webmcp:deleted_items", {});
1008
+ expect(onConfirm.mock.calls[1]?.[0]).toMatchObject({ confirmRequired: true });
1009
+ expect(onConfirm.mock.calls[2]?.[0]).not.toHaveProperty("confirmRequired");
1010
+ });
1011
+
1012
+ it("merges widget and server patterns", async () => {
1013
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
1014
+ registry.tools = [fakeTool({ name: "pay_now" }), fakeTool({ name: "wipe_cart" })];
1015
+ const bridge = runtimeBridge(
1016
+ { enabled: true, onConfirm, requireConfirmFor: ["pay_*"] },
1017
+ () => ["wipe_*"],
1018
+ );
1019
+ await bridge.executeToolCall("webmcp:pay_now", {});
1020
+ await bridge.executeToolCall("webmcp:wipe_cart", {});
1021
+ expect(onConfirm.mock.calls.map((c) => c[0]?.confirmRequired)).toEqual([true, true]);
1022
+ });
1023
+
1024
+ it.each([[undefined], [[] as string[]]])(
1025
+ "an absent or empty list leaves the gate unflagged (%s)",
1026
+ async (list) => {
1027
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => true);
1028
+ registry.tools = [fakeTool({ name: "checkout_submit" })];
1029
+ const bridge = runtimeBridge({ enabled: true, onConfirm, requireConfirmFor: list }, () => list);
1030
+ await bridge.executeToolCall("webmcp:checkout_submit", {});
1031
+ expect(onConfirm.mock.calls[0]?.[0]).not.toHaveProperty("confirmRequired");
1032
+ },
1033
+ );
1034
+
1035
+ it("fails closed while the server policy is not loaded (null) or its read throws", async () => {
1036
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => false);
1037
+ const execute = vi.fn(() => "ran");
1038
+ registry.tools = [fakeTool({ name: "next_slide", annotations: { readOnlyHint: true }, execute })];
1039
+ const unloaded = runtimeBridge({ enabled: true, onConfirm }, () => null);
1040
+ const throwing = runtimeBridge({ enabled: true, onConfirm }, () => {
1041
+ throw new Error("boom");
1042
+ });
1043
+ for (const bridge of [unloaded, throwing]) {
1044
+ const r = await bridge.executeVoiceToolCall("webmcp:next_slide", {}, { autoConfirmReadOnly: true });
1045
+ expect(r.status).toBe("denied");
1046
+ }
1047
+ expect(onConfirm).toHaveBeenCalledTimes(2);
1048
+ expect(onConfirm.mock.calls.map((c) => c[0]?.confirmRequired)).toEqual([true, true]);
1049
+ expect(execute).not.toHaveBeenCalled();
1050
+ });
1051
+
1052
+ it("does not let the voice read-only fast tier skip a matched tool", async () => {
1053
+ const onConfirm = vi.fn<WebMcpConfirmHandler>(async () => false);
1054
+ const execute = vi.fn(() => "ran");
1055
+ registry.tools = [
1056
+ fakeTool({ name: "checkout_preview", annotations: { readOnlyHint: true }, execute }),
1057
+ ];
1058
+ const bridge = runtimeBridge({ enabled: true, onConfirm }, () => ["checkout_*"]);
1059
+ const r = await bridge.executeVoiceToolCall("webmcp:checkout_preview", {}, {
1060
+ autoConfirmReadOnly: true,
1061
+ });
1062
+ expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ confirmRequired: true }));
1063
+ expect(execute).not.toHaveBeenCalled();
1064
+ expect(r.status).toBe("denied");
1065
+ });
1066
+ });
@@ -35,6 +35,14 @@ export interface WebMcpBridgeDeps {
35
35
  getToolDisplayTitle: (toolName: string) => string | undefined;
36
36
  /** Core's `loadWebMcpPolyfillModule` (slot-aware polyfill resolution). */
37
37
  loadPolyfill: () => Promise<WebMcpPolyfillModule>;
38
+ /**
39
+ * Server-supplied `requireConfirmFor` patterns (the bound surface's policy
40
+ * from client-token init), read at call time because init can land after
41
+ * the bridge is built. Merged with `config.requireConfirmFor`. `null` means
42
+ * a policy is expected but not loaded yet: every call then needs
43
+ * confirmation (fail closed).
44
+ */
45
+ getRequireConfirmFor?: () => readonly string[] | null | undefined;
38
46
  }
39
47
 
40
48
  const DEFAULT_TOOL_TIMEOUT_MS = 30_000;
@@ -339,7 +347,13 @@ export class WebMcpBridge {
339
347
  ...(signal ? { signal } : {}),
340
348
  reason: "gate",
341
349
  };
342
- const skipGate = autoConfirmReadOnly && annotations?.readOnlyHint === true;
350
+ // A `requireConfirmFor` match always reaches the confirm handler: it skips
351
+ // `autoApprove` on the default UI (session.ts) and the voice read-only
352
+ // fast tier here.
353
+ const confirmRequired = this.#requiresConfirmation(bareName);
354
+ if (confirmRequired) gateInfo.confirmRequired = true;
355
+ const skipGate =
356
+ !confirmRequired && autoConfirmReadOnly && annotations?.readOnlyHint === true;
343
357
  if (!skipGate && !(await this.#requestConfirm(gateInfo))) {
344
358
  return runFailure("denied", "User declined the tool call.");
345
359
  }
@@ -487,6 +501,20 @@ export class WebMcpBridge {
487
501
  }
488
502
  }
489
503
 
504
+ #requiresConfirmation(toolName: string): boolean {
505
+ let serverPatterns: readonly string[] | null | undefined;
506
+ try {
507
+ serverPatterns = this.#deps.getRequireConfirmFor?.();
508
+ } catch {
509
+ serverPatterns = null;
510
+ }
511
+ if (serverPatterns === null) return true;
512
+ const matches = (list: readonly string[] | undefined): boolean =>
513
+ Array.isArray(list) &&
514
+ list.some((pattern) => typeof pattern === "string" && matchesGlob(toolName, pattern));
515
+ return matches(this.config.requireConfirmFor) || matches(serverPatterns);
516
+ }
517
+
490
518
  #passesClientAllowlist(toolName: string): boolean {
491
519
  const list = this.config.allowlist;
492
520
  if (!list || list.length === 0) return true;
@@ -1 +0,0 @@
1
- const e=`data-persona-tooltip-suppressed`,t=`persona-composer-control`,n=`persona-composer-control--glyph`,r=(e,t,n)=>{let r=e.ownerDocument?.defaultView;if(!r)return;let i=r.getComputedStyle(e);for(let e of n){let n=i.getPropertyValue(e).trim();n?t.style.setProperty(e,n):t.style.removeProperty(e)}};export{e as i,n,r,t};