@agent-native/toolkit 0.209.2 → 0.209.3-nightly-20261016212700
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/app/extensions/EmbeddedExtension.d.ts.map +1 -1
- package/dist/app/extensions/EmbeddedExtension.js +11 -3
- package/dist/app/extensions/EmbeddedExtension.js.map +1 -1
- package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
- package/dist/app/extensions/ExtensionViewer.js +12 -2
- package/dist/app/extensions/ExtensionViewer.js.map +1 -1
- package/dist/app/extensions/InlineExtensionFrame.d.ts.map +1 -1
- package/dist/app/extensions/InlineExtensionFrame.js +10 -2
- package/dist/app/extensions/InlineExtensionFrame.js.map +1 -1
- package/dist/app/extensions/open-extension-link.d.ts +15 -0
- package/dist/app/extensions/open-extension-link.d.ts.map +1 -0
- package/dist/app/extensions/open-extension-link.js +76 -0
- package/dist/app/extensions/open-extension-link.js.map +1 -0
- package/dist/composer/ComposerContextPickerDialog.d.ts.map +1 -1
- package/dist/composer/ComposerContextPickerDialog.js +56 -14
- package/dist/composer/ComposerContextPickerDialog.js.map +1 -1
- package/dist/composer/index.d.ts +1 -0
- package/dist/composer/index.d.ts.map +1 -1
- package/dist/composer/index.js +1 -0
- package/dist/composer/index.js.map +1 -1
- package/dist/composer/useComposerContextPicker.d.ts +5 -1
- package/dist/composer/useComposerContextPicker.d.ts.map +1 -1
- package/dist/composer/useComposerContextPicker.js.map +1 -1
- package/package.json +4 -4
- package/src/app/extensions/EmbeddedExtension.tsx +19 -3
- package/src/app/extensions/ExtensionViewer.spec.tsx +100 -2
- package/src/app/extensions/ExtensionViewer.tsx +20 -2
- package/src/app/extensions/InlineExtensionFrame.spec.tsx +136 -0
- package/src/app/extensions/InlineExtensionFrame.tsx +18 -2
- package/src/app/extensions/open-extension-link.spec.ts +141 -0
- package/src/app/extensions/open-extension-link.ts +94 -0
- package/src/composer/ComposerContextPickerDialog.spec.tsx +229 -0
- package/src/composer/ComposerContextPickerDialog.tsx +61 -17
- package/src/composer/index.ts +1 -0
- 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(
|
|
314
|
+
expect(loadedFrame().getAttribute("srcdoc")).toContain(
|
|
315
|
+
"Star history chart",
|
|
316
|
+
);
|
|
220
317
|
});
|
|
318
|
+
expect(spinner()).toBeTruthy();
|
|
221
319
|
await act(async () => {
|
|
222
|
-
|
|
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
|
+
}
|