@agent-native/toolkit 0.209.2 → 0.209.3-nightly-20261016183441

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 (35) hide show
  1. package/dist/app/extensions/EmbeddedExtension.d.ts.map +1 -1
  2. package/dist/app/extensions/EmbeddedExtension.js +11 -3
  3. package/dist/app/extensions/EmbeddedExtension.js.map +1 -1
  4. package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
  5. package/dist/app/extensions/ExtensionViewer.js +12 -2
  6. package/dist/app/extensions/ExtensionViewer.js.map +1 -1
  7. package/dist/app/extensions/InlineExtensionFrame.d.ts.map +1 -1
  8. package/dist/app/extensions/InlineExtensionFrame.js +10 -2
  9. package/dist/app/extensions/InlineExtensionFrame.js.map +1 -1
  10. package/dist/app/extensions/open-extension-link.d.ts +15 -0
  11. package/dist/app/extensions/open-extension-link.d.ts.map +1 -0
  12. package/dist/app/extensions/open-extension-link.js +76 -0
  13. package/dist/app/extensions/open-extension-link.js.map +1 -0
  14. package/dist/composer/ComposerContextPickerDialog.d.ts.map +1 -1
  15. package/dist/composer/ComposerContextPickerDialog.js +56 -14
  16. package/dist/composer/ComposerContextPickerDialog.js.map +1 -1
  17. package/dist/composer/index.d.ts +1 -0
  18. package/dist/composer/index.d.ts.map +1 -1
  19. package/dist/composer/index.js +1 -0
  20. package/dist/composer/index.js.map +1 -1
  21. package/dist/composer/useComposerContextPicker.d.ts +5 -1
  22. package/dist/composer/useComposerContextPicker.d.ts.map +1 -1
  23. package/dist/composer/useComposerContextPicker.js.map +1 -1
  24. package/package.json +4 -4
  25. package/src/app/extensions/EmbeddedExtension.tsx +19 -3
  26. package/src/app/extensions/ExtensionViewer.spec.tsx +100 -2
  27. package/src/app/extensions/ExtensionViewer.tsx +20 -2
  28. package/src/app/extensions/InlineExtensionFrame.spec.tsx +136 -0
  29. package/src/app/extensions/InlineExtensionFrame.tsx +18 -2
  30. package/src/app/extensions/open-extension-link.spec.ts +141 -0
  31. package/src/app/extensions/open-extension-link.ts +94 -0
  32. package/src/composer/ComposerContextPickerDialog.spec.tsx +229 -0
  33. package/src/composer/ComposerContextPickerDialog.tsx +61 -17
  34. package/src/composer/index.ts +1 -0
  35. package/src/composer/useComposerContextPicker.ts +8 -1
@@ -127,6 +127,7 @@ describe("ExtensionViewer MCP embeds", () => {
127
127
  act(() => root.unmount());
128
128
  queryClient.clear();
129
129
  container.remove();
130
+ vi.restoreAllMocks();
130
131
  vi.unstubAllGlobals();
131
132
  });
132
133
 
@@ -158,6 +159,96 @@ describe("ExtensionViewer MCP embeds", () => {
158
159
  );
159
160
  });
160
161
 
162
+ it("opens links only for the shell document that announced the binding", async () => {
163
+ document.body.appendChild(container);
164
+ const iframe = await renderViewer();
165
+ const frameWindow = iframe.contentWindow as Window;
166
+ expect(frameWindow).toBeTruthy();
167
+ const replies: unknown[] = [];
168
+ vi.spyOn(frameWindow, "postMessage").mockImplementation((data: unknown) => {
169
+ replies.push(data);
170
+ });
171
+ const open = vi.spyOn(window, "open").mockImplementation(() => null);
172
+ const post = (data: unknown) =>
173
+ act(() => {
174
+ window.dispatchEvent(
175
+ new MessageEvent("message", { data, source: frameWindow }),
176
+ );
177
+ });
178
+ const token = "a".repeat(32);
179
+ const url = "https://app.hubspot.com/contacts/1/company/2";
180
+
181
+ post({ type: "agent-native-extension-open-url", url, token });
182
+ expect(open).not.toHaveBeenCalled();
183
+
184
+ post({
185
+ type: "agent-native-extension-binding",
186
+ binding: { role: "viewer" },
187
+ openUrlToken: token,
188
+ });
189
+ expect(replies).toContainEqual({
190
+ type: "agent-native-extension-open-url-ready",
191
+ });
192
+
193
+ // A page the iframe navigated to can't re-announce or guess the token.
194
+ post({
195
+ type: "agent-native-extension-binding",
196
+ binding: { role: "owner" },
197
+ openUrlToken: "b".repeat(32),
198
+ });
199
+ post({
200
+ type: "agent-native-extension-open-url",
201
+ url,
202
+ token: "b".repeat(32),
203
+ });
204
+ post({ type: "agent-native-extension-open-url", url });
205
+ post({
206
+ type: "agent-native-extension-open-url",
207
+ url: "javascript:alert(1)",
208
+ token,
209
+ });
210
+ expect(open).not.toHaveBeenCalled();
211
+
212
+ post({ type: "agent-native-extension-open-url", url, token });
213
+ expect(open).toHaveBeenCalledTimes(1);
214
+ expect(open).toHaveBeenCalledWith(url, "_blank", "noopener,noreferrer");
215
+ });
216
+
217
+ it("leaves links native inside MCP chat embeds, where the host can't escape the sandbox either", async () => {
218
+ embedState.active = true;
219
+ document.body.appendChild(container);
220
+ const iframe = await renderViewer();
221
+ const frameWindow = iframe.contentWindow as Window;
222
+ const replies: unknown[] = [];
223
+ vi.spyOn(frameWindow, "postMessage").mockImplementation((data: unknown) => {
224
+ replies.push(data);
225
+ });
226
+ const open = vi.spyOn(window, "open").mockImplementation(() => null);
227
+ const token = "d".repeat(32);
228
+ const post = (data: unknown) =>
229
+ act(() => {
230
+ window.dispatchEvent(
231
+ new MessageEvent("message", { data, source: frameWindow }),
232
+ );
233
+ });
234
+
235
+ post({
236
+ type: "agent-native-extension-binding",
237
+ binding: { role: "viewer" },
238
+ openUrlToken: token,
239
+ });
240
+ post({
241
+ type: "agent-native-extension-open-url",
242
+ url: "https://example.com/",
243
+ token,
244
+ });
245
+
246
+ expect(replies).not.toContainEqual({
247
+ type: "agent-native-extension-open-url-ready",
248
+ });
249
+ expect(open).not.toHaveBeenCalled();
250
+ });
251
+
161
252
  it("uses sandboxed srcdoc inside MCP chat embeds to avoid a blocked nested route frame", async () => {
162
253
  embedState.active = true;
163
254
  const iframe = await renderViewer();
@@ -215,11 +306,18 @@ describe("ExtensionViewer MCP embeds", () => {
215
306
  await act(async () => {
216
307
  resolveSources(Response.json(displaySourcesResponse));
217
308
  });
309
+ // The real document gets a fresh iframe element (a new window), so look
310
+ // it up again rather than reusing the blank frame's.
311
+ const loadedFrame = () =>
312
+ container.querySelector("iframe") as HTMLIFrameElement;
218
313
  await vi.waitFor(() => {
219
- expect(iframe.getAttribute("srcdoc")).toContain("Star history chart");
314
+ expect(loadedFrame().getAttribute("srcdoc")).toContain(
315
+ "Star history chart",
316
+ );
220
317
  });
318
+ expect(spinner()).toBeTruthy();
221
319
  await act(async () => {
222
- iframe.dispatchEvent(new Event("load"));
320
+ loadedFrame().dispatchEvent(new Event("load"));
223
321
  await new Promise((resolve) => setTimeout(resolve, 200));
224
322
  });
225
323
  expect(spinner()).toBeNull();
@@ -66,6 +66,11 @@ import {
66
66
  type ExtensionBridgeRole,
67
67
  } from "./iframe-bridge.js";
68
68
  import { useExtensionIframeMetaCsp } from "./iframe-display-sources.js";
69
+ import {
70
+ EXTENSION_OPEN_URL_MESSAGE,
71
+ useExtensionFrameKey,
72
+ useOpenUrlLatch,
73
+ } from "./open-extension-link.js";
69
74
  import { normalizeAgentNativeExtensionSandbox } from "./portable-extension.js";
70
75
 
71
76
  const THEME_CSS_VARS = [
@@ -737,6 +742,7 @@ export function ExtensionViewer({
737
742
  isAuthor: false,
738
743
  });
739
744
  const bindingLatchedRef = useRef(false);
745
+ const openUrl = useOpenUrlLatch();
740
746
 
741
747
  useEffect(() => {
742
748
  setIsDark(document.documentElement.classList.contains("dark"));
@@ -776,6 +782,9 @@ export function ExtensionViewer({
776
782
  if (!message) return;
777
783
 
778
784
  if (message.type === "agent-native-extension-binding") {
785
+ // Inside an MCP chat embed this page is itself sandboxed, so its
786
+ // window.open can't escape either; leave links native there.
787
+ openUrl.latch(message, event.source, !isEmbedMcpChatBridgeActive());
779
788
  if (bindingLatchedRef.current) return;
780
789
  bindingLatchedRef.current = true;
781
790
  const binding = message.binding ?? {};
@@ -812,6 +821,11 @@ export function ExtensionViewer({
812
821
  return;
813
822
  }
814
823
 
824
+ if (message.type === EXTENSION_OPEN_URL_MESSAGE) {
825
+ openUrl.open(message);
826
+ return;
827
+ }
828
+
815
829
  if (message.type === "agent-native-send-to-chat") {
816
830
  const text = serializeChatValue(message.message);
817
831
  if (!text?.trim()) return;
@@ -985,7 +999,7 @@ export function ExtensionViewer({
985
999
 
986
1000
  window.addEventListener("message", handleMessage);
987
1001
  return () => window.removeEventListener("message", handleMessage);
988
- }, [extensionId, queryClient]);
1002
+ }, [extensionId, openUrl, queryClient]);
989
1003
 
990
1004
  const {
991
1005
  data: extension,
@@ -1060,6 +1074,10 @@ export function ExtensionViewer({
1060
1074
  if (!extension || !usesSrcDoc || !iframeMetaCsp) return undefined;
1061
1075
  return buildExtensionViewerSrcDoc(extension, isDark, iframeMetaCsp);
1062
1076
  }, [extension, iframeMetaCsp, isDark, usesSrcDoc]);
1077
+
1078
+ const frameKey = useExtensionFrameKey(
1079
+ `${extensionId}|${extension?.updatedAt}|${refreshKey}|${usesSrcDoc ? "" : iframeSrc}|${iframeSrcDoc ?? ""}`,
1080
+ );
1063
1081
  const unavailableStatus = extensionLoadErrorStatus(
1064
1082
  extensionError ?? extensionFailureReason,
1065
1083
  );
@@ -1287,7 +1305,7 @@ export function ExtensionViewer({
1287
1305
  <iframe
1288
1306
  {...{ [SESSION_REPLAY_IFRAME_ATTRIBUTE]: "" }}
1289
1307
  ref={iframeRef}
1290
- key={`${extension.updatedAt}-${refreshKey}`}
1308
+ key={`${extension.updatedAt}-${refreshKey}-${frameKey}`}
1291
1309
  src={usesSrcDoc ? undefined : iframeSrc}
1292
1310
  srcDoc={iframeSrcDoc}
1293
1311
  className="h-full w-full border-0"
@@ -43,6 +43,7 @@ describe("InlineExtensionFrame", () => {
43
43
  afterEach(() => {
44
44
  act(() => root.unmount());
45
45
  container.remove();
46
+ vi.restoreAllMocks();
46
47
  vi.unstubAllGlobals();
47
48
  });
48
49
 
@@ -253,6 +254,141 @@ describe("InlineExtensionFrame", () => {
253
254
  });
254
255
  });
255
256
 
257
+ it("keeps opening links with the live document's token after a re-render that doesn't reload it", async () => {
258
+ const open = vi.spyOn(window, "open").mockImplementation(() => null);
259
+ const extension = {
260
+ id: "inline-links",
261
+ mode: "transient" as const,
262
+ name: "Links",
263
+ content: '<a href="https://app.hubspot.com/x" target="_blank">x</a>',
264
+ };
265
+ await act(async () => {
266
+ root.render(
267
+ <InlineExtensionFrame extension={extension} initialHeight={200} />,
268
+ );
269
+ });
270
+ const iframe = container.querySelector("iframe");
271
+ const frameWindow = iframe?.contentWindow as Window;
272
+ const post = (data: unknown) =>
273
+ act(() => {
274
+ window.dispatchEvent(
275
+ new MessageEvent("message", { source: frameWindow, data }),
276
+ );
277
+ });
278
+ const token = "c".repeat(32);
279
+ post({
280
+ type: "agent-native-extension-binding",
281
+ binding: { role: "owner", isAuthor: true },
282
+ openUrlToken: token,
283
+ });
284
+
285
+ // initialHeight resets the binding latch, but the iframe keeps its document.
286
+ await act(async () => {
287
+ root.render(
288
+ <InlineExtensionFrame extension={extension} initialHeight={320} />,
289
+ );
290
+ });
291
+ expect(container.querySelector("iframe")?.contentWindow).toBe(frameWindow);
292
+
293
+ post({
294
+ type: "agent-native-extension-open-url",
295
+ url: "https://app.hubspot.com/x",
296
+ token,
297
+ });
298
+ expect(open).toHaveBeenCalledWith(
299
+ "https://app.hubspot.com/x",
300
+ "_blank",
301
+ "noopener,noreferrer",
302
+ );
303
+ });
304
+
305
+ it("binds the open-url token to the document the host loaded, not to re-renders", async () => {
306
+ const open = vi.spyOn(window, "open").mockImplementation(() => null);
307
+ vi.spyOn(console, "warn").mockImplementation(() => {});
308
+ const extension = {
309
+ id: "inline-links-doc",
310
+ mode: "transient" as const,
311
+ name: "Links",
312
+ content: '<a href="https://app.hubspot.com/x" target="_blank">x</a>',
313
+ };
314
+ const render = async (props: {
315
+ content?: string;
316
+ initialHeight?: number;
317
+ }) =>
318
+ act(async () => {
319
+ root.render(
320
+ <InlineExtensionFrame
321
+ extension={{
322
+ ...extension,
323
+ content: props.content ?? extension.content,
324
+ }}
325
+ initialHeight={props.initialHeight ?? 200}
326
+ />,
327
+ );
328
+ });
329
+ const post = (data: unknown) =>
330
+ act(() => {
331
+ window.dispatchEvent(
332
+ new MessageEvent("message", {
333
+ source: container.querySelector("iframe")?.contentWindow,
334
+ data,
335
+ }),
336
+ );
337
+ });
338
+ const bind = (token: string) =>
339
+ post({
340
+ type: "agent-native-extension-binding",
341
+ binding: { role: "owner", isAuthor: true },
342
+ openUrlToken: token,
343
+ });
344
+ const openWith = (token: string) =>
345
+ post({
346
+ type: "agent-native-extension-open-url",
347
+ url: "https://example.com/",
348
+ token,
349
+ });
350
+
351
+ await render({});
352
+ bind("a".repeat(32));
353
+
354
+ // The frame navigated to another page, then a host-only re-render reset
355
+ // the binding latch. That page's binding must not latch a new token.
356
+ const firstFrame = container.querySelector("iframe");
357
+ await render({ initialHeight: 320 });
358
+ expect(container.querySelector("iframe")).toBe(firstFrame);
359
+ bind("b".repeat(32));
360
+ openWith("b".repeat(32));
361
+ expect(open).not.toHaveBeenCalled();
362
+
363
+ // New content is a new document from the host, in a fresh iframe element.
364
+ // A binding still queued from the previous document's window is ignored,
365
+ // and the new document's token latches.
366
+ // (Browsers keep one window per iframe element across its documents, so
367
+ // the element itself must be new; happy-dom swaps windows on srcdoc
368
+ // changes regardless, which is why this checks the element.)
369
+ const previousFrame = container.querySelector("iframe");
370
+ const previousWindow = previousFrame?.contentWindow;
371
+ await render({ content: "<a href='https://example.com/'>y</a>" });
372
+ expect(container.querySelector("iframe")).not.toBe(previousFrame);
373
+ act(() => {
374
+ window.dispatchEvent(
375
+ new MessageEvent("message", {
376
+ source: previousWindow,
377
+ data: {
378
+ type: "agent-native-extension-binding",
379
+ binding: { role: "owner", isAuthor: true },
380
+ openUrlToken: "d".repeat(32),
381
+ },
382
+ }),
383
+ );
384
+ });
385
+ bind("c".repeat(32));
386
+ openWith("d".repeat(32));
387
+ expect(open).not.toHaveBeenCalled();
388
+ openWith("c".repeat(32));
389
+ expect(open).toHaveBeenCalledTimes(1);
390
+ });
391
+
256
392
  it("dispatches passive output events from generated UI", async () => {
257
393
  await act(async () => {
258
394
  root.render(
@@ -14,6 +14,11 @@ import {
14
14
  type ExtensionBridgeRole,
15
15
  } from "./iframe-bridge.js";
16
16
  import { useExtensionIframeMetaCsp } from "./iframe-display-sources.js";
17
+ import {
18
+ EXTENSION_OPEN_URL_MESSAGE,
19
+ useExtensionFrameKey,
20
+ useOpenUrlLatch,
21
+ } from "./open-extension-link.js";
17
22
  import { normalizeAgentNativeExtensionSandbox } from "./portable-extension.js";
18
23
 
19
24
  const THEME_CSS_VARS = [
@@ -339,6 +344,7 @@ export function InlineExtensionFrame({
339
344
  isAuthor: !!providedExtension?.content,
340
345
  });
341
346
  const bindingLatchedRef = useRef(false);
347
+ const openUrl = useOpenUrlLatch();
342
348
  const [fetchedExtension, setFetchedExtension] =
343
349
  useState<InlineExtensionDefinition | null>(null);
344
350
  const [isLoading, setIsLoading] = useState(false);
@@ -412,6 +418,10 @@ export function InlineExtensionFrame({
412
418
  );
413
419
  }, [extension, iframeMetaCsp, isTransient]);
414
420
 
421
+ const frameKey = useExtensionFrameKey(
422
+ `${resolvedId}|${extension?.updatedAt ?? "inline"}|${isTransient}|${iframeSrc ?? ""}|${srcDoc ?? ""}`,
423
+ );
424
+
415
425
  useEffect(() => {
416
426
  bridgeContextRef.current = isTransient
417
427
  ? { role: "owner", isAuthor: true }
@@ -466,6 +476,7 @@ export function InlineExtensionFrame({
466
476
  if (!message || typeof message !== "object") return;
467
477
 
468
478
  if (message.type === "agent-native-extension-binding") {
479
+ openUrl.latch(message, event.source);
469
480
  if (bindingLatchedRef.current) return;
470
481
  bindingLatchedRef.current = true;
471
482
  const binding = (message as any).binding ?? {};
@@ -506,6 +517,11 @@ export function InlineExtensionFrame({
506
517
  return;
507
518
  }
508
519
 
520
+ if (message.type === EXTENSION_OPEN_URL_MESSAGE) {
521
+ openUrl.open(message);
522
+ return;
523
+ }
524
+
509
525
  if (message.type === "agent-native-send-to-chat") {
510
526
  const text = serializeChatValue((message as any).message);
511
527
  if (!text?.trim()) return;
@@ -655,7 +671,7 @@ export function InlineExtensionFrame({
655
671
 
656
672
  window.addEventListener("message", handleMessage);
657
673
  return () => window.removeEventListener("message", handleMessage);
658
- }, [extension, isTransient, resolvedId]);
674
+ }, [extension, isTransient, openUrl, resolvedId]);
659
675
 
660
676
  if (!extension) {
661
677
  if (!isLoading) return null;
@@ -673,7 +689,7 @@ export function InlineExtensionFrame({
673
689
  <iframe
674
690
  {...{ [SESSION_REPLAY_IFRAME_ATTRIBUTE]: "" }}
675
691
  ref={iframeRef}
676
- key={`${resolvedId}-${extension.updatedAt ?? "inline"}-${isTransient ? "transient" : "persisted"}`}
692
+ key={`${resolvedId}-${extension.updatedAt ?? "inline"}-${isTransient ? "transient" : "persisted"}-${frameKey}`}
677
693
  src={iframeSrc}
678
694
  srcDoc={srcDoc}
679
695
  title={extension.name}
@@ -0,0 +1,141 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ createOpenUrlLatch,
5
+ openExtensionLink,
6
+ readOpenUrlToken,
7
+ } from "./open-extension-link.js";
8
+
9
+ const TOKEN = "0123456789abcdef0123456789abcdef";
10
+
11
+ describe("openExtensionLink", () => {
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ vi.unstubAllGlobals();
15
+ });
16
+
17
+ it("opens web links in a new tab without an opener", () => {
18
+ const open = vi.fn();
19
+ vi.stubGlobal("window", { open });
20
+ const url = "https://app.hubspot.com/contacts/1/company/2";
21
+
22
+ expect(openExtensionLink({ url, token: TOKEN }, TOKEN)).toBe(true);
23
+ expect(open).toHaveBeenCalledWith(url, "_blank", "noopener,noreferrer");
24
+ });
25
+
26
+ it("refuses and warns about requests without the latched token", () => {
27
+ const open = vi.fn();
28
+ vi.stubGlobal("window", { open });
29
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
30
+ const url = "https://example.com/";
31
+
32
+ expect(openExtensionLink({ url }, TOKEN)).toBe(false);
33
+ expect(openExtensionLink({ url, token: "other" }, TOKEN)).toBe(false);
34
+ expect(openExtensionLink({ url, token: TOKEN }, null)).toBe(false);
35
+ expect(openExtensionLink(null, TOKEN)).toBe(false);
36
+ expect(open).not.toHaveBeenCalled();
37
+ expect(warn).toHaveBeenCalledTimes(4);
38
+ });
39
+
40
+ it("refuses and warns about anything that isn't an http(s) URL", () => {
41
+ const open = vi.fn();
42
+ vi.stubGlobal("window", { open });
43
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
44
+ const urls = [
45
+ "javascript:alert(1)",
46
+ "data:text/html,<script>alert(1)</script>",
47
+ "blob:https://example.com/1",
48
+ "/relative/path",
49
+ "",
50
+ 42,
51
+ undefined,
52
+ ];
53
+
54
+ for (const url of urls) {
55
+ expect(openExtensionLink({ url, token: TOKEN }, TOKEN)).toBe(false);
56
+ }
57
+ expect(open).not.toHaveBeenCalled();
58
+ expect(warn).toHaveBeenCalledTimes(urls.length);
59
+ });
60
+ });
61
+
62
+ describe("readOpenUrlToken", () => {
63
+ it("only latches a 32-character hex token", () => {
64
+ expect(readOpenUrlToken({ openUrlToken: TOKEN })).toBe(TOKEN);
65
+ expect(readOpenUrlToken({ openUrlToken: TOKEN.toUpperCase() })).toBeNull();
66
+ expect(readOpenUrlToken({ openUrlToken: "short" })).toBeNull();
67
+ expect(readOpenUrlToken({ openUrlToken: `${TOKEN}0` })).toBeNull();
68
+ expect(readOpenUrlToken({ openUrlToken: "" })).toBeNull();
69
+ expect(readOpenUrlToken({ openUrlToken: 42 })).toBeNull();
70
+ expect(readOpenUrlToken(null)).toBeNull();
71
+ });
72
+ });
73
+
74
+ describe("createOpenUrlLatch", () => {
75
+ const OTHER = "fedcba9876543210fedcba9876543210";
76
+ const READY = { type: "agent-native-extension-open-url-ready" };
77
+ const frameWindow = () => ({ postMessage: vi.fn() }) as unknown as Window;
78
+ const binding = (openUrlToken: string) => ({
79
+ type: "agent-native-extension-binding",
80
+ openUrlToken,
81
+ });
82
+ const request = (token: string) => ({ url: "https://example.com/", token });
83
+
84
+ afterEach(() => {
85
+ vi.restoreAllMocks();
86
+ vi.unstubAllGlobals();
87
+ });
88
+
89
+ it("latches a window's first binding and ignores later ones from it", () => {
90
+ const open = vi.fn();
91
+ vi.stubGlobal("window", { open });
92
+ vi.spyOn(console, "warn").mockImplementation(() => {});
93
+ const latch = createOpenUrlLatch();
94
+ const frame = frameWindow();
95
+
96
+ latch.latch(binding(TOKEN), frame);
97
+ // A page the frame navigated to re-announces from the same window.
98
+ latch.latch(binding(OTHER), frame);
99
+
100
+ expect(frame.postMessage).toHaveBeenCalledTimes(1);
101
+ expect(frame.postMessage).toHaveBeenCalledWith(READY, "*");
102
+ expect(latch.open(request(OTHER))).toBe(false);
103
+ expect(latch.open(request(TOKEN))).toBe(true);
104
+ expect(open).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("latches afresh for a new iframe window, however it was mounted", () => {
108
+ const open = vi.fn();
109
+ vi.stubGlobal("window", { open });
110
+ vi.spyOn(console, "warn").mockImplementation(() => {});
111
+ const latch = createOpenUrlLatch();
112
+ const first = frameWindow();
113
+ const remounted = frameWindow();
114
+
115
+ latch.latch(binding(TOKEN), first);
116
+ latch.latch(binding(OTHER), remounted);
117
+
118
+ expect(remounted.postMessage).toHaveBeenCalledWith(READY, "*");
119
+ expect(latch.open(request(OTHER))).toBe(true);
120
+ // The old window's token no longer opens anything.
121
+ expect(latch.open(request(TOKEN))).toBe(false);
122
+ expect(open).toHaveBeenCalledTimes(1);
123
+ });
124
+
125
+ it("never opens or offers to when disabled or the binding has no token", () => {
126
+ const open = vi.fn();
127
+ vi.stubGlobal("window", { open });
128
+ vi.spyOn(console, "warn").mockImplementation(() => {});
129
+ const disabled = createOpenUrlLatch();
130
+ const frame = frameWindow();
131
+ disabled.latch(binding(TOKEN), frame, false);
132
+ const tokenless = createOpenUrlLatch();
133
+ const other = frameWindow();
134
+ tokenless.latch({ type: "agent-native-extension-binding" }, other);
135
+
136
+ expect(frame.postMessage).not.toHaveBeenCalled();
137
+ expect(other.postMessage).not.toHaveBeenCalled();
138
+ expect(disabled.open(request(TOKEN))).toBe(false);
139
+ expect(open).not.toHaveBeenCalled();
140
+ });
141
+ });
@@ -0,0 +1,94 @@
1
+ import { useMemo } from "react";
2
+
3
+ export const EXTENSION_OPEN_URL_MESSAGE = "agent-native-extension-open-url";
4
+ export const EXTENSION_OPEN_URL_READY_MESSAGE =
5
+ "agent-native-extension-open-url-ready";
6
+
7
+ const OPEN_URL_TOKEN_RE = /^[0-9a-f]{32}$/;
8
+
9
+ // The extension shell announces a random token with its binding, before any
10
+ // extension code runs. Hosts latch it with the binding and only honor
11
+ // open-url requests that carry it, so a page the iframe navigated to can't
12
+ // use this. A binding without a valid token means the shell can't route
13
+ // links, so the host doesn't offer to.
14
+ export function readOpenUrlToken(message: unknown): string | null {
15
+ const token = (message as { openUrlToken?: unknown } | null)?.openUrlToken;
16
+ return typeof token === "string" && OPEN_URL_TOKEN_RE.test(token)
17
+ ? token
18
+ : null;
19
+ }
20
+
21
+ // Tells the shell this host opens links, so it starts routing them here.
22
+ export function announceOpenUrlSupport(source: MessageEventSource | null) {
23
+ (source as Window | null)?.postMessage?.(
24
+ { type: EXTENSION_OPEN_URL_READY_MESSAGE },
25
+ "*",
26
+ );
27
+ }
28
+
29
+ // Opens a link on behalf of a sandboxed extension iframe. A tab the iframe
30
+ // opened itself would inherit its sandbox (an opaque origin, so apps like
31
+ // HubSpot render blank); opening from the host gives a normal tab, and
32
+ // noopener keeps that tab from reaching back into the host page.
33
+ export function openExtensionLink(
34
+ message: unknown,
35
+ expectedToken: string | null,
36
+ ): boolean {
37
+ const { url, token } = (message ?? {}) as { url?: unknown; token?: unknown };
38
+ if (!expectedToken || token !== expectedToken) {
39
+ console.warn(
40
+ "[extensions] Ignored an open-url request without this frame's token.",
41
+ );
42
+ return false;
43
+ }
44
+ const parsed =
45
+ typeof url === "string" && URL.canParse(url) ? new URL(url) : null;
46
+ if (
47
+ !parsed ||
48
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
49
+ ) {
50
+ console.warn("[extensions] Ignored an open-url request for a non-web URL.");
51
+ return false;
52
+ }
53
+ window.open(parsed.href, "_blank", "noopener,noreferrer");
54
+ return true;
55
+ }
56
+
57
+ // Binds link opening to one iframe window: the first binding from a window
58
+ // latches its token, later bindings from that window are ignored (a page the
59
+ // frame navigated to can't latch its own), and open-url requests must carry
60
+ // the latched token. A new iframe element is a new window, so it latches
61
+ // afresh (replacing the token) however it came to be mounted. Hosts must
62
+ // only pass messages whose event.source is their current iframe's window.
63
+ export function createOpenUrlLatch() {
64
+ let latchedWindow: MessageEventSource | null = null;
65
+ let token: string | null = null;
66
+ return {
67
+ latch(message: unknown, source: MessageEventSource | null, enabled = true) {
68
+ if (!source || source === latchedWindow) return;
69
+ latchedWindow = source;
70
+ token = enabled ? readOpenUrlToken(message) : null;
71
+ if (token) announceOpenUrlSupport(source);
72
+ },
73
+ open(message: unknown) {
74
+ return openExtensionLink(message, token);
75
+ },
76
+ };
77
+ }
78
+
79
+ export function useOpenUrlLatch() {
80
+ return useMemo(createOpenUrlLatch, []);
81
+ }
82
+
83
+ let frameDocuments = 0;
84
+
85
+ // A key for the iframe's `key` that changes whenever `documentIdentity`
86
+ // (whatever determines the frame's document: its src, srcdoc or key) does.
87
+ // The frame's window outlives its documents, so without a new element a new
88
+ // document would share the window the latch already holds. A new element is
89
+ // a new window, so the new document latches its own token and messages from
90
+ // any earlier document fail the hosts' event.source check.
91
+ export function useExtensionFrameKey(documentIdentity: string): string {
92
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- a new document is what makes a new key
93
+ return useMemo(() => `doc-${++frameDocuments}`, [documentIdentity]);
94
+ }