@agent-native/dispatch 0.15.28 → 0.16.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 (80) hide show
  1. package/dist/actions/create-browser-chat-session.d.ts +15 -0
  2. package/dist/actions/create-browser-chat-session.d.ts.map +1 -0
  3. package/dist/actions/create-browser-chat-session.js +75 -0
  4. package/dist/actions/create-browser-chat-session.js.map +1 -0
  5. package/dist/actions/delete-destination.d.ts +12 -12
  6. package/dist/actions/index.d.ts.map +1 -1
  7. package/dist/actions/index.js +2 -0
  8. package/dist/actions/index.js.map +1 -1
  9. package/dist/actions/provider-api-register.d.ts +13 -13
  10. package/dist/actions/upsert-destination.d.ts +12 -12
  11. package/dist/actions/view-screen.js +4 -2
  12. package/dist/actions/view-screen.js.map +1 -1
  13. package/dist/components/create-app-popover.d.ts.map +1 -1
  14. package/dist/components/create-app-popover.js +7 -0
  15. package/dist/components/create-app-popover.js.map +1 -1
  16. package/dist/components/layout/Layout.d.ts.map +1 -1
  17. package/dist/components/layout/Layout.js +3 -4
  18. package/dist/components/layout/Layout.js.map +1 -1
  19. package/dist/config.d.ts +5 -0
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js.map +1 -1
  22. package/dist/hooks/use-navigation-state.js +2 -0
  23. package/dist/hooks/use-navigation-state.js.map +1 -1
  24. package/dist/lib/automations.d.ts.map +1 -1
  25. package/dist/lib/automations.js +5 -4
  26. package/dist/lib/automations.js.map +1 -1
  27. package/dist/lib/browser-chat-bridge.d.ts +8 -0
  28. package/dist/lib/browser-chat-bridge.d.ts.map +1 -0
  29. package/dist/lib/browser-chat-bridge.js +48 -0
  30. package/dist/lib/browser-chat-bridge.js.map +1 -0
  31. package/dist/lib/browser-chat-protocol.d.ts +33 -0
  32. package/dist/lib/browser-chat-protocol.d.ts.map +1 -0
  33. package/dist/lib/browser-chat-protocol.js +82 -0
  34. package/dist/lib/browser-chat-protocol.js.map +1 -0
  35. package/dist/routes/index.d.ts.map +1 -1
  36. package/dist/routes/index.js +2 -0
  37. package/dist/routes/index.js.map +1 -1
  38. package/dist/routes/pages/browser-chat.d.ts +5 -0
  39. package/dist/routes/pages/browser-chat.d.ts.map +1 -0
  40. package/dist/routes/pages/browser-chat.js +43 -0
  41. package/dist/routes/pages/browser-chat.js.map +1 -0
  42. package/dist/routes/pages/browser-connect.d.ts +5 -0
  43. package/dist/routes/pages/browser-connect.d.ts.map +1 -0
  44. package/dist/routes/pages/browser-connect.js +72 -0
  45. package/dist/routes/pages/browser-connect.js.map +1 -0
  46. package/dist/server/lib/browser-extension-allowlist.d.ts +9 -0
  47. package/dist/server/lib/browser-extension-allowlist.d.ts.map +1 -0
  48. package/dist/server/lib/browser-extension-allowlist.js +39 -0
  49. package/dist/server/lib/browser-extension-allowlist.js.map +1 -0
  50. package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
  51. package/dist/server/lib/usage-metrics-store.js +2 -1
  52. package/dist/server/lib/usage-metrics-store.js.map +1 -1
  53. package/package.json +3 -3
  54. package/src/actions/create-browser-chat-session.spec.ts +122 -0
  55. package/src/actions/create-browser-chat-session.ts +99 -0
  56. package/src/actions/index.ts +2 -0
  57. package/src/actions/view-screen.ts +5 -2
  58. package/src/components/create-app-popover.spec.tsx +53 -0
  59. package/src/components/create-app-popover.tsx +49 -0
  60. package/src/components/layout/Layout.spec.tsx +0 -1
  61. package/src/components/layout/Layout.tsx +2 -6
  62. package/src/config.ts +5 -0
  63. package/src/hooks/use-navigation-state.spec.ts +7 -0
  64. package/src/hooks/use-navigation-state.ts +1 -0
  65. package/src/lib/automations.spec.ts +64 -0
  66. package/src/lib/automations.ts +5 -3
  67. package/src/lib/browser-chat-bridge.spec.ts +151 -0
  68. package/src/lib/browser-chat-bridge.ts +79 -0
  69. package/src/lib/browser-chat-protocol.spec.ts +113 -0
  70. package/src/lib/browser-chat-protocol.ts +124 -0
  71. package/src/routes/index.spec.ts +6 -0
  72. package/src/routes/index.ts +2 -0
  73. package/src/routes/pages/browser-chat.spec.tsx +121 -0
  74. package/src/routes/pages/browser-chat.tsx +89 -0
  75. package/src/routes/pages/browser-connect.spec.tsx +137 -0
  76. package/src/routes/pages/browser-connect.tsx +143 -0
  77. package/src/server/lib/browser-extension-allowlist.spec.ts +76 -0
  78. package/src/server/lib/browser-extension-allowlist.ts +58 -0
  79. package/src/server/lib/usage-metrics-store.spec.ts +23 -2
  80. package/src/server/lib/usage-metrics-store.ts +2 -1
@@ -0,0 +1,121 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { MemoryRouter } from "react-router";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import BrowserChatRoute from "./browser-chat.js";
8
+
9
+ const browserChat = vi.hoisted(() => ({
10
+ install: vi.fn(() => vi.fn()),
11
+ embedActive: true,
12
+ surfaceProps: null as Record<string, unknown> | null,
13
+ }));
14
+
15
+ vi.mock("@agent-native/core/client/agent-chat", () => ({
16
+ AgentChatSurface: (props: Record<string, unknown>) => {
17
+ browserChat.surfaceProps = props;
18
+ return <div data-testid="browser-agent-chat" />;
19
+ },
20
+ }));
21
+ vi.mock("@agent-native/core/client/host", () => ({
22
+ isEmbedAuthActive: () => browserChat.embedActive,
23
+ }));
24
+ vi.mock("@agent-native/core/client/i18n", () => ({
25
+ useT: () => (key: string, values?: { page?: string }) =>
26
+ ({
27
+ "dispatch.pages.browserChatUnavailableTitle":
28
+ "Browser chat session unavailable",
29
+ "dispatch.pages.browserChatUnavailableDescription":
30
+ "Reconnect from the Agent-Native browser extension.",
31
+ "dispatch.pages.browserChatPlaceholder": "Ask about this page…",
32
+ "dispatch.pages.browserChatAttachedPlaceholder": `Ask about ${values?.page}…`,
33
+ })[key] ?? key,
34
+ }));
35
+
36
+ vi.mock("../../lib/browser-chat-bridge.js", () => ({
37
+ installBrowserChatBridge: browserChat.install,
38
+ }));
39
+
40
+ describe("BrowserChatRoute", () => {
41
+ let container: HTMLDivElement;
42
+ let root: Root;
43
+
44
+ beforeEach(() => {
45
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
46
+ browserChat.install.mockClear();
47
+ browserChat.embedActive = true;
48
+ browserChat.surfaceProps = null;
49
+ container = document.createElement("div");
50
+ document.body.appendChild(container);
51
+ root = createRoot(container);
52
+ });
53
+
54
+ afterEach(() => {
55
+ act(() => root.unmount());
56
+ container.remove();
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ it("renders the canonical narrow chat and installs the scoped bridge", async () => {
61
+ await act(async () => {
62
+ root.render(
63
+ <MemoryRouter
64
+ initialEntries={[
65
+ "/browser-chat?browserChatNonce=browser-chat-nonce-1234567890&browserChatParentOrigin=chrome-extension%3A%2F%2Fabcdefghijklmnopabcdefghijklmnop",
66
+ ]}
67
+ >
68
+ <BrowserChatRoute />
69
+ </MemoryRouter>,
70
+ );
71
+ });
72
+
73
+ expect(browserChat.install).toHaveBeenCalledWith(
74
+ expect.objectContaining({
75
+ nonce: "browser-chat-nonce-1234567890",
76
+ parentOrigin: "chrome-extension://abcdefghijklmnopabcdefghijklmnop",
77
+ }),
78
+ );
79
+ expect(browserChat.surfaceProps).toMatchObject({
80
+ mode: "panel",
81
+ storageKey: "dispatch",
82
+ showHeader: false,
83
+ showTabBar: false,
84
+ allowSettingsMode: false,
85
+ composerPlaceholder: "Ask about this page…",
86
+ });
87
+ });
88
+
89
+ it("fails closed when the bridge query is incomplete", async () => {
90
+ await act(async () => {
91
+ root.render(
92
+ <MemoryRouter initialEntries={["/browser-chat"]}>
93
+ <BrowserChatRoute />
94
+ </MemoryRouter>,
95
+ );
96
+ });
97
+
98
+ expect(container.textContent).toContain("Browser chat session unavailable");
99
+ expect(browserChat.install).not.toHaveBeenCalled();
100
+ expect(browserChat.surfaceProps).toBeNull();
101
+ });
102
+
103
+ it("does not install the bridge for a normally authenticated top-level route", async () => {
104
+ browserChat.embedActive = false;
105
+
106
+ await act(async () => {
107
+ root.render(
108
+ <MemoryRouter
109
+ initialEntries={[
110
+ "/browser-chat?browserChatNonce=browser-chat-nonce-1234567890&browserChatParentOrigin=chrome-extension%3A%2F%2Fabcdefghijklmnopabcdefghijklmnop",
111
+ ]}
112
+ >
113
+ <BrowserChatRoute />
114
+ </MemoryRouter>,
115
+ );
116
+ });
117
+
118
+ expect(browserChat.install).not.toHaveBeenCalled();
119
+ expect(browserChat.surfaceProps).toBeNull();
120
+ });
121
+ });
@@ -0,0 +1,89 @@
1
+ import { AgentChatSurface } from "@agent-native/core/client/agent-chat";
2
+ import { isEmbedAuthActive } from "@agent-native/core/client/host";
3
+ import { useT } from "@agent-native/core/client/i18n";
4
+ import { useEffect, useState } from "react";
5
+ import { useSearchParams } from "react-router";
6
+
7
+ import { installBrowserChatBridge } from "../../lib/browser-chat-bridge.js";
8
+ import {
9
+ BROWSER_CHAT_NONCE_QUERY_PARAM,
10
+ BROWSER_CHAT_PARENT_ORIGIN_QUERY_PARAM,
11
+ browserChatExtensionOriginSchema,
12
+ browserChatNonceSchema,
13
+ type BrowserChatMessageV1,
14
+ } from "../../lib/browser-chat-protocol.js";
15
+
16
+ export function meta() {
17
+ return [{ title: "Dispatch" }];
18
+ }
19
+
20
+ export default function BrowserChatRoute() {
21
+ const t = useT();
22
+ const [searchParams] = useSearchParams();
23
+ const nonce = browserChatNonceSchema.safeParse(
24
+ searchParams.get(BROWSER_CHAT_NONCE_QUERY_PARAM),
25
+ );
26
+ const parentOrigin = browserChatExtensionOriginSchema.safeParse(
27
+ searchParams.get(BROWSER_CHAT_PARENT_ORIGIN_QUERY_PARAM),
28
+ );
29
+ const embedSessionActive = isEmbedAuthActive();
30
+ const [attachedPage, setAttachedPage] = useState<string | null>(null);
31
+
32
+ useEffect(() => {
33
+ if (!nonce.success || !parentOrigin.success || !embedSessionActive) return;
34
+ return installBrowserChatBridge({
35
+ nonce: nonce.data,
36
+ parentOrigin: parentOrigin.data,
37
+ onAccepted: (message: BrowserChatMessageV1) =>
38
+ setAttachedPage(message.context.page.title),
39
+ });
40
+ }, [
41
+ nonce.data,
42
+ nonce.success,
43
+ parentOrigin.data,
44
+ parentOrigin.success,
45
+ embedSessionActive,
46
+ ]);
47
+
48
+ if (!nonce.success || !parentOrigin.success || !embedSessionActive) {
49
+ return (
50
+ <main className="grid min-h-screen place-items-center bg-background p-6 text-center">
51
+ <div className="max-w-sm">
52
+ <h1 className="text-base font-medium text-foreground">
53
+ {t("dispatch.pages.browserChatUnavailableTitle")}
54
+ </h1>
55
+ <p className="mt-2 text-sm text-muted-foreground">
56
+ {t("dispatch.pages.browserChatUnavailableDescription")}
57
+ </p>
58
+ </div>
59
+ </main>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <main className="flex h-screen min-h-0 flex-col bg-background">
65
+ <div className="min-h-0 flex-1">
66
+ <AgentChatSurface
67
+ mode="panel"
68
+ className="h-full"
69
+ defaultMode="chat"
70
+ storageKey="dispatch"
71
+ showHeader={false}
72
+ showTabBar={false}
73
+ allowSettingsMode={false}
74
+ dynamicSuggestions={false}
75
+ suggestions={[]}
76
+ emptyStateDisplay="hidden"
77
+ centerComposerWhenEmpty={false}
78
+ composerPlaceholder={
79
+ attachedPage
80
+ ? t("dispatch.pages.browserChatAttachedPlaceholder", {
81
+ page: attachedPage,
82
+ })
83
+ : t("dispatch.pages.browserChatPlaceholder")
84
+ }
85
+ />
86
+ </div>
87
+ </main>
88
+ );
89
+ }
@@ -0,0 +1,137 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { MemoryRouter } from "react-router";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import BrowserConnectRoute from "./browser-connect.js";
8
+
9
+ const browserConnect = vi.hoisted(() => ({
10
+ mutateAsync: vi.fn(),
11
+ sendMessage: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("@agent-native/core/client/hooks", () => ({
15
+ useActionMutation: () => ({
16
+ mutateAsync: browserConnect.mutateAsync,
17
+ isPending: false,
18
+ }),
19
+ }));
20
+
21
+ vi.mock("@agent-native/core/client/i18n", () => ({
22
+ useT: () => (key: string) =>
23
+ ({
24
+ "dispatch.pages.browserConnectTitle": "Connect browser chat",
25
+ "dispatch.pages.browserConnectDescription": "Description",
26
+ "dispatch.pages.browserConnectInvalid": "Invalid request",
27
+ "dispatch.pages.browserConnectConnected": "Connected",
28
+ "dispatch.pages.browserConnectConnecting": "Connecting",
29
+ "dispatch.pages.browserConnectButton": "Connect",
30
+ "dispatch.pages.browserConnectOpenFromExtension": "Open from extension",
31
+ "dispatch.pages.browserConnectFailed": "Connection failed",
32
+ })[key] ?? key,
33
+ }));
34
+
35
+ const extensionId = "abcdefghijklmnopabcdefghijklmnop";
36
+ const nonce = "browser-chat-nonce-1234567890";
37
+
38
+ describe("BrowserConnectRoute", () => {
39
+ let container: HTMLDivElement;
40
+ let root: Root;
41
+
42
+ beforeEach(() => {
43
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
44
+ browserConnect.mutateAsync.mockReset();
45
+ browserConnect.sendMessage.mockReset();
46
+ browserConnect.sendMessage.mockImplementation(
47
+ (
48
+ _extensionId: string,
49
+ _message: Record<string, unknown>,
50
+ callback: (response: { ok: boolean }) => void,
51
+ ) => callback({ ok: true }),
52
+ );
53
+ vi.stubGlobal("chrome", {
54
+ runtime: {
55
+ sendMessage: browserConnect.sendMessage,
56
+ },
57
+ });
58
+ container = document.createElement("div");
59
+ document.body.appendChild(container);
60
+ root = createRoot(container);
61
+ });
62
+
63
+ afterEach(() => {
64
+ act(() => root.unmount());
65
+ container.remove();
66
+ vi.unstubAllGlobals();
67
+ });
68
+
69
+ it("requires an explicit click before sending a one-time session to the exact extension", async () => {
70
+ browserConnect.mutateAsync.mockResolvedValue({
71
+ startPath: "/_agent-native/embed/start?ticket=one-time-ticket",
72
+ expiresAt: 1_900_000_000_000,
73
+ parentOrigin: `chrome-extension://${extensionId}`,
74
+ remoteDevice: {
75
+ id: "remote-device-example",
76
+ token: "anr_example",
77
+ },
78
+ relayBaseUrl: "https://dispatch.example.com",
79
+ });
80
+
81
+ await act(async () => {
82
+ root.render(
83
+ <MemoryRouter
84
+ initialEntries={[
85
+ `/browser-connect?extensionId=${extensionId}&nonce=${nonce}`,
86
+ ]}
87
+ >
88
+ <BrowserConnectRoute />
89
+ </MemoryRouter>,
90
+ );
91
+ });
92
+ expect(browserConnect.mutateAsync).not.toHaveBeenCalled();
93
+
94
+ await act(async () => {
95
+ container.querySelector("button")?.click();
96
+ });
97
+
98
+ expect(browserConnect.mutateAsync).toHaveBeenCalledWith({
99
+ extensionId,
100
+ nonce,
101
+ });
102
+ expect(browserConnect.sendMessage).toHaveBeenCalledWith(
103
+ extensionId,
104
+ {
105
+ type: "browser-chat.session.v1",
106
+ nonce,
107
+ startPath: "/_agent-native/embed/start?ticket=one-time-ticket",
108
+ dispatchOrigin: window.location.origin,
109
+ expiresAt: "2030-03-17T17:46:40.000Z",
110
+ remoteDevice: {
111
+ id: "remote-device-example",
112
+ token: "anr_example",
113
+ },
114
+ relayBaseUrl: "https://dispatch.example.com",
115
+ },
116
+ expect.any(Function),
117
+ );
118
+ expect(container.textContent).toContain("Connected");
119
+ });
120
+
121
+ it("fails closed on malformed connection parameters", async () => {
122
+ await act(async () => {
123
+ root.render(
124
+ <MemoryRouter
125
+ initialEntries={[
126
+ "/browser-connect?extensionId=wildcard&nonce=too-short",
127
+ ]}
128
+ >
129
+ <BrowserConnectRoute />
130
+ </MemoryRouter>,
131
+ );
132
+ });
133
+
134
+ expect(container.textContent).toContain("Invalid request");
135
+ expect(container.querySelector("button")).toBeNull();
136
+ });
137
+ });
@@ -0,0 +1,143 @@
1
+ import { useActionMutation } from "@agent-native/core/client/hooks";
2
+ import { useT } from "@agent-native/core/client/i18n";
3
+ import { IconBrowserCheck, IconPlugConnected } from "@tabler/icons-react";
4
+ import { useState } from "react";
5
+ import { useSearchParams } from "react-router";
6
+
7
+ import { Button } from "../../components/ui/button.js";
8
+ import {
9
+ browserChatExtensionIdSchema,
10
+ browserChatNonceSchema,
11
+ } from "../../lib/browser-chat-protocol.js";
12
+
13
+ interface BrowserChatSessionResult {
14
+ startPath: string;
15
+ expiresAt: number;
16
+ parentOrigin: string;
17
+ remoteDevice: {
18
+ id: string;
19
+ token: string;
20
+ };
21
+ relayBaseUrl: string;
22
+ }
23
+
24
+ interface ChromeRuntimeApi {
25
+ lastError?: { message?: string };
26
+ sendMessage(
27
+ extensionId: string,
28
+ message: Record<string, unknown>,
29
+ callback: (response?: { ok?: boolean; error?: string }) => void,
30
+ ): void;
31
+ }
32
+
33
+ function sendBrowserChatSession(
34
+ extensionId: string,
35
+ message: Record<string, unknown>,
36
+ ): Promise<void> {
37
+ const runtime = (
38
+ globalThis as typeof globalThis & {
39
+ chrome?: { runtime?: ChromeRuntimeApi };
40
+ }
41
+ ).chrome?.runtime;
42
+ if (!runtime) {
43
+ return Promise.reject(new Error("extension-unavailable"));
44
+ }
45
+
46
+ return new Promise((resolve, reject) => {
47
+ runtime.sendMessage(extensionId, message, (response) => {
48
+ const runtimeError = runtime.lastError?.message;
49
+ if (runtimeError) {
50
+ reject(new Error(runtimeError));
51
+ } else if (response?.ok !== true) {
52
+ reject(new Error("extension-rejected"));
53
+ } else {
54
+ resolve();
55
+ }
56
+ });
57
+ });
58
+ }
59
+
60
+ export function meta() {
61
+ return [{ title: "Dispatch" }];
62
+ }
63
+
64
+ export default function BrowserConnectRoute() {
65
+ const t = useT();
66
+ const [searchParams] = useSearchParams();
67
+ const extensionId = searchParams.get("extensionId") ?? "";
68
+ const parsedExtensionId = browserChatExtensionIdSchema.safeParse(extensionId);
69
+ const nonce = browserChatNonceSchema.safeParse(searchParams.get("nonce"));
70
+ const requestIsValid = parsedExtensionId.success && nonce.success;
71
+ const [connected, setConnected] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ const createSession = useActionMutation<
74
+ BrowserChatSessionResult,
75
+ { extensionId: string; nonce: string }
76
+ >("create-browser-chat-session", {});
77
+
78
+ const connect = async () => {
79
+ if (!requestIsValid) return;
80
+ setError(null);
81
+ try {
82
+ const session = await createSession.mutateAsync({
83
+ extensionId,
84
+ nonce: nonce.data,
85
+ });
86
+ await sendBrowserChatSession(extensionId, {
87
+ type: "browser-chat.session.v1",
88
+ nonce: nonce.data,
89
+ startPath: session.startPath,
90
+ dispatchOrigin: window.location.origin,
91
+ expiresAt: new Date(session.expiresAt).toISOString(),
92
+ remoteDevice: session.remoteDevice,
93
+ relayBaseUrl: session.relayBaseUrl,
94
+ });
95
+ setConnected(true);
96
+ } catch (cause) {
97
+ setError(
98
+ cause instanceof Error && cause.message === "extension-unavailable"
99
+ ? t("dispatch.pages.browserConnectOpenFromExtension")
100
+ : t("dispatch.pages.browserConnectFailed"),
101
+ );
102
+ }
103
+ };
104
+
105
+ return (
106
+ <main className="grid min-h-screen place-items-center bg-background p-6">
107
+ <div className="w-full max-w-sm rounded-2xl border bg-card p-6 text-center shadow-sm">
108
+ <IconBrowserCheck size={36} className="mx-auto text-muted-foreground" />
109
+ <h1 className="mt-4 text-lg font-semibold text-foreground">
110
+ {t("dispatch.pages.browserConnectTitle")}
111
+ </h1>
112
+ <p className="mt-2 text-sm text-muted-foreground">
113
+ {t("dispatch.pages.browserConnectDescription")}
114
+ </p>
115
+ {!requestIsValid ? (
116
+ <p className="mt-4 text-sm text-destructive">
117
+ {t("dispatch.pages.browserConnectInvalid")}
118
+ </p>
119
+ ) : connected ? (
120
+ <p className="mt-4 text-sm font-medium text-foreground">
121
+ {t("dispatch.pages.browserConnectConnected")}
122
+ </p>
123
+ ) : (
124
+ <Button
125
+ className="mt-5 w-full gap-2"
126
+ onClick={() => void connect()}
127
+ disabled={createSession.isPending}
128
+ >
129
+ <IconPlugConnected size={16} />
130
+ {createSession.isPending
131
+ ? t("dispatch.pages.browserConnectConnecting")
132
+ : t("dispatch.pages.browserConnectButton")}
133
+ </Button>
134
+ )}
135
+ {error ? (
136
+ <p className="mt-3 text-sm text-destructive" role="alert">
137
+ {error}
138
+ </p>
139
+ ) : null}
140
+ </div>
141
+ </main>
142
+ );
143
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ isBrowserExtensionIdAllowed,
5
+ resolveAllowedBrowserExtensionIds,
6
+ } from "./browser-extension-allowlist.js";
7
+
8
+ const extensionId = "abcdefghijklmnopabcdefghijklmnop";
9
+
10
+ describe("browser extension allowlist", () => {
11
+ it("combines exact configured and environment ids", () => {
12
+ expect(
13
+ resolveAllowedBrowserExtensionIds(
14
+ [extensionId, extensionId],
15
+ "ponmlkjihgfedcbaponmlkjihgfedcba",
16
+ ),
17
+ ).toEqual(new Set([extensionId, "ponmlkjihgfedcbaponmlkjihgfedcba"]));
18
+ });
19
+
20
+ it("fails loudly for invalid entries instead of producing an empty list", () => {
21
+ expect(() =>
22
+ resolveAllowedBrowserExtensionIds([], "not-an-extension-id"),
23
+ ).toThrow("invalid Chrome extension id");
24
+ expect(() =>
25
+ resolveAllowedBrowserExtensionIds(
26
+ Array.from({ length: 65 }, (_, index) => {
27
+ const suffix = index
28
+ .toString(16)
29
+ .padStart(2, "0")
30
+ .replace(/[0-9a-f]/g, (digit) =>
31
+ "abcdefghijklmnop".charAt("0123456789abcdef".indexOf(digit)),
32
+ );
33
+ return `${"a".repeat(30)}${suffix}`;
34
+ }),
35
+ ),
36
+ ).toThrow("cannot exceed 64");
37
+ });
38
+
39
+ it("requires an exact production allowlist match", () => {
40
+ expect(
41
+ isBrowserExtensionIdAllowed({
42
+ extensionId,
43
+ configIds: [extensionId],
44
+ nodeEnv: "production",
45
+ requestOrigin: "https://dispatch.example.com",
46
+ }),
47
+ ).toBe(true);
48
+ expect(
49
+ isBrowserExtensionIdAllowed({
50
+ extensionId,
51
+ configIds: [],
52
+ nodeEnv: "production",
53
+ requestOrigin: "http://localhost:8092",
54
+ }),
55
+ ).toBe(false);
56
+ });
57
+
58
+ it("allows unpacked extension ids only from a loopback non-production app", () => {
59
+ expect(
60
+ isBrowserExtensionIdAllowed({
61
+ extensionId,
62
+ configIds: [],
63
+ nodeEnv: "development",
64
+ requestOrigin: "http://localhost:8092",
65
+ }),
66
+ ).toBe(true);
67
+ expect(
68
+ isBrowserExtensionIdAllowed({
69
+ extensionId,
70
+ configIds: [],
71
+ nodeEnv: "development",
72
+ requestOrigin: "https://dispatch.example.com",
73
+ }),
74
+ ).toBe(false);
75
+ });
76
+ });
@@ -0,0 +1,58 @@
1
+ const CHROME_EXTENSION_ID = /^[a-p]{32}$/;
2
+ const MAX_BROWSER_EXTENSION_IDS = 64;
3
+
4
+ export function resolveAllowedBrowserExtensionIds(
5
+ configIds: readonly string[] = [],
6
+ envValue = process.env.AGENT_NATIVE_BROWSER_EXTENSION_IDS,
7
+ ): Set<string> {
8
+ const ids = [...configIds, ...(envValue?.split(",") ?? [])]
9
+ .map((value) => value.trim())
10
+ .filter(Boolean);
11
+ const invalid = ids.find((value) => !CHROME_EXTENSION_ID.test(value));
12
+ if (invalid) {
13
+ throw new Error(
14
+ "Browser extension allowlist contains an invalid Chrome extension id.",
15
+ );
16
+ }
17
+ const uniqueIds = new Set(ids);
18
+ if (uniqueIds.size > MAX_BROWSER_EXTENSION_IDS) {
19
+ throw new Error(
20
+ `Browser extension allowlist cannot exceed ${MAX_BROWSER_EXTENSION_IDS} ids.`,
21
+ );
22
+ }
23
+ return uniqueIds;
24
+ }
25
+
26
+ function isLoopbackOrigin(value: string | undefined): boolean {
27
+ if (!value) return false;
28
+ try {
29
+ const url = new URL(value);
30
+ return (
31
+ (url.protocol === "http:" || url.protocol === "https:") &&
32
+ (url.hostname === "localhost" ||
33
+ url.hostname === "127.0.0.1" ||
34
+ url.hostname === "[::1]")
35
+ );
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export function isBrowserExtensionIdAllowed(options: {
42
+ extensionId: string;
43
+ configIds?: readonly string[];
44
+ envValue?: string;
45
+ nodeEnv?: string;
46
+ requestOrigin?: string;
47
+ }): boolean {
48
+ if (!CHROME_EXTENSION_ID.test(options.extensionId)) return false;
49
+ const configured = resolveAllowedBrowserExtensionIds(
50
+ options.configIds,
51
+ options.envValue,
52
+ );
53
+ if (configured.has(options.extensionId)) return true;
54
+
55
+ return (
56
+ options.nodeEnv !== "production" && isLoopbackOrigin(options.requestOrigin)
57
+ );
58
+ }
@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
7
7
  getSetting: vi.fn(async () => null),
8
8
  getUsageSummary: vi.fn(),
9
9
  listWorkspaceApps: vi.fn(),
10
+ currentOrgId: vi.fn((): string | null => null),
11
+ currentOwnerEmail: vi.fn(() => "owner@example.test"),
10
12
  registerBuiltinEngines: vi.fn(),
11
13
  }));
12
14
 
@@ -45,17 +47,36 @@ vi.mock("./app-creation-store.js", () => ({
45
47
  }));
46
48
 
47
49
  vi.mock("./dispatch-store.js", () => ({
48
- currentOrgId: () => null,
49
- currentOwnerEmail: () => "owner@example.test",
50
+ currentOrgId: () => mocks.currentOrgId(),
51
+ currentOwnerEmail: () => mocks.currentOwnerEmail(),
50
52
  }));
51
53
 
52
54
  const { listDispatchUsageMetrics } = await import("./usage-metrics-store.js");
53
55
 
54
56
  afterEach(() => {
55
57
  vi.clearAllMocks();
58
+ mocks.currentOrgId.mockReturnValue(null);
59
+ mocks.currentOwnerEmail.mockReturnValue("owner@example.test");
56
60
  });
57
61
 
58
62
  describe("listDispatchUsageMetrics", () => {
63
+ it("rejects non-admin organization members with a typed 403", async () => {
64
+ mocks.currentOrgId.mockReturnValue("org-a");
65
+ mocks.currentOwnerEmail.mockReturnValue("member@example.test");
66
+ mocks.execute.mockResolvedValue({ rows: [{ role: "member" }] });
67
+
68
+ await expect(
69
+ listDispatchUsageMetrics({ sinceDays: 30 }),
70
+ ).rejects.toMatchObject({
71
+ name: "ForbiddenError",
72
+ statusCode: 403,
73
+ message:
74
+ "Only organization owners and admins can view workspace usage metrics.",
75
+ });
76
+ expect(mocks.getUsageSummary).not.toHaveBeenCalled();
77
+ expect(mocks.listWorkspaceApps).not.toHaveBeenCalled();
78
+ });
79
+
59
80
  it("returns empty metrics when usage storage bootstrap and reads fail", async () => {
60
81
  mocks.getUsageSummary.mockRejectedValue(new Error("database is locked"));
61
82
  mocks.execute.mockRejectedValue(new Error("no such table: token_usage"));
@@ -8,6 +8,7 @@ import {
8
8
  } from "@agent-native/core/agent/engine";
9
9
  import { getDbExec } from "@agent-native/core/db";
10
10
  import { getSetting } from "@agent-native/core/settings";
11
+ import { ForbiddenError } from "@agent-native/core/sharing";
11
12
  import {
12
13
  getUsageSummary,
13
14
  usageBillingForEngine,
@@ -395,7 +396,7 @@ async function assertCanViewMetrics(): Promise<{
395
396
  if (!orgId) {
396
397
  return { viewerEmail, orgId, role };
397
398
  }
398
- throw new Error(
399
+ throw new ForbiddenError(
399
400
  "Only organization owners and admins can view workspace usage metrics.",
400
401
  );
401
402
  }