@agent-native/dispatch 0.15.29 → 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.
- package/dist/actions/create-browser-chat-session.d.ts +15 -0
- package/dist/actions/create-browser-chat-session.d.ts.map +1 -0
- package/dist/actions/create-browser-chat-session.js +75 -0
- package/dist/actions/create-browser-chat-session.js.map +1 -0
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/provider-api-register.d.ts +14 -14
- package/dist/actions/update-workspace-resource.d.ts +13 -13
- package/dist/actions/upsert-destination.d.ts +12 -12
- package/dist/actions/view-screen.js +4 -2
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/create-app-popover.d.ts.map +1 -1
- package/dist/components/create-app-popover.js +7 -0
- package/dist/components/create-app-popover.js.map +1 -1
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +3 -4
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks/use-navigation-state.js +2 -0
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/lib/automations.d.ts.map +1 -1
- package/dist/lib/automations.js +5 -4
- package/dist/lib/automations.js.map +1 -1
- package/dist/lib/browser-chat-bridge.d.ts +8 -0
- package/dist/lib/browser-chat-bridge.d.ts.map +1 -0
- package/dist/lib/browser-chat-bridge.js +48 -0
- package/dist/lib/browser-chat-bridge.js.map +1 -0
- package/dist/lib/browser-chat-protocol.d.ts +33 -0
- package/dist/lib/browser-chat-protocol.d.ts.map +1 -0
- package/dist/lib/browser-chat-protocol.js +82 -0
- package/dist/lib/browser-chat-protocol.js.map +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/browser-chat.d.ts +5 -0
- package/dist/routes/pages/browser-chat.d.ts.map +1 -0
- package/dist/routes/pages/browser-chat.js +43 -0
- package/dist/routes/pages/browser-chat.js.map +1 -0
- package/dist/routes/pages/browser-connect.d.ts +5 -0
- package/dist/routes/pages/browser-connect.d.ts.map +1 -0
- package/dist/routes/pages/browser-connect.js +72 -0
- package/dist/routes/pages/browser-connect.js.map +1 -0
- package/dist/server/lib/browser-extension-allowlist.d.ts +9 -0
- package/dist/server/lib/browser-extension-allowlist.d.ts.map +1 -0
- package/dist/server/lib/browser-extension-allowlist.js +39 -0
- package/dist/server/lib/browser-extension-allowlist.js.map +1 -0
- package/package.json +3 -3
- package/src/actions/create-browser-chat-session.spec.ts +122 -0
- package/src/actions/create-browser-chat-session.ts +99 -0
- package/src/actions/index.ts +2 -0
- package/src/actions/view-screen.ts +5 -2
- package/src/components/create-app-popover.spec.tsx +53 -0
- package/src/components/create-app-popover.tsx +49 -0
- package/src/components/layout/Layout.spec.tsx +0 -1
- package/src/components/layout/Layout.tsx +2 -6
- package/src/config.ts +5 -0
- package/src/hooks/use-navigation-state.spec.ts +7 -0
- package/src/hooks/use-navigation-state.ts +1 -0
- package/src/lib/automations.spec.ts +64 -0
- package/src/lib/automations.ts +5 -3
- package/src/lib/browser-chat-bridge.spec.ts +151 -0
- package/src/lib/browser-chat-bridge.ts +79 -0
- package/src/lib/browser-chat-protocol.spec.ts +113 -0
- package/src/lib/browser-chat-protocol.ts +124 -0
- package/src/routes/index.spec.ts +6 -0
- package/src/routes/index.ts +2 -0
- package/src/routes/pages/browser-chat.spec.tsx +121 -0
- package/src/routes/pages/browser-chat.tsx +89 -0
- package/src/routes/pages/browser-connect.spec.tsx +137 -0
- package/src/routes/pages/browser-connect.tsx +143 -0
- package/src/server/lib/browser-extension-allowlist.spec.ts +76 -0
- package/src/server/lib/browser-extension-allowlist.ts +58 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const CHROME_EXTENSION_ID = /^[a-p]{32}$/;
|
|
2
|
+
const MAX_BROWSER_EXTENSION_IDS = 64;
|
|
3
|
+
export function resolveAllowedBrowserExtensionIds(configIds = [], envValue = process.env.AGENT_NATIVE_BROWSER_EXTENSION_IDS) {
|
|
4
|
+
const ids = [...configIds, ...(envValue?.split(",") ?? [])]
|
|
5
|
+
.map((value) => value.trim())
|
|
6
|
+
.filter(Boolean);
|
|
7
|
+
const invalid = ids.find((value) => !CHROME_EXTENSION_ID.test(value));
|
|
8
|
+
if (invalid) {
|
|
9
|
+
throw new Error("Browser extension allowlist contains an invalid Chrome extension id.");
|
|
10
|
+
}
|
|
11
|
+
const uniqueIds = new Set(ids);
|
|
12
|
+
if (uniqueIds.size > MAX_BROWSER_EXTENSION_IDS) {
|
|
13
|
+
throw new Error(`Browser extension allowlist cannot exceed ${MAX_BROWSER_EXTENSION_IDS} ids.`);
|
|
14
|
+
}
|
|
15
|
+
return uniqueIds;
|
|
16
|
+
}
|
|
17
|
+
function isLoopbackOrigin(value) {
|
|
18
|
+
if (!value)
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
return ((url.protocol === "http:" || url.protocol === "https:") &&
|
|
23
|
+
(url.hostname === "localhost" ||
|
|
24
|
+
url.hostname === "127.0.0.1" ||
|
|
25
|
+
url.hostname === "[::1]"));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function isBrowserExtensionIdAllowed(options) {
|
|
32
|
+
if (!CHROME_EXTENSION_ID.test(options.extensionId))
|
|
33
|
+
return false;
|
|
34
|
+
const configured = resolveAllowedBrowserExtensionIds(options.configIds, options.envValue);
|
|
35
|
+
if (configured.has(options.extensionId))
|
|
36
|
+
return true;
|
|
37
|
+
return (options.nodeEnv !== "production" && isLoopbackOrigin(options.requestOrigin));
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=browser-extension-allowlist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-extension-allowlist.js","sourceRoot":"","sources":["../../../src/server/lib/browser-extension-allowlist.ts"],"names":[],"mappings":"AAAA,MAAM,mBAAmB,GAAG,aAAa,CAAC;AAC1C,MAAM,yBAAyB,GAAG,EAAE,CAAC;AAErC,MAAM,UAAU,iCAAiC,CAC/C,SAAS,GAAsB,EAAE,EACjC,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kCAAkC;IAEzD,MAAM,GAAG,GAAG,CAAC,GAAG,SAAS,EAAE,GAAG,CAAC,QAAQ,EAAE,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;SACxD,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;SAC5B,MAAM,CAAC,OAAO,CAAC,CAAC;IACnB,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,mBAAmB,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC;IACtE,IAAI,OAAO,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CACb,sEAAsE,CACvE,CAAC;IACJ,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,SAAS,CAAC,IAAI,GAAG,yBAAyB,EAAE,CAAC;QAC/C,MAAM,IAAI,KAAK,CACb,6CAA6C,yBAAyB,OAAO,CAC9E,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,gBAAgB,CAAC,KAAyB;IACjD,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC;QAC3B,OAAO,CACL,CAAC,GAAG,CAAC,QAAQ,KAAK,OAAO,IAAI,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC;YACvD,CAAC,GAAG,CAAC,QAAQ,KAAK,WAAW;gBAC3B,GAAG,CAAC,QAAQ,KAAK,WAAW;gBAC5B,GAAG,CAAC,QAAQ,KAAK,OAAO,CAAC,CAC5B,CAAC;IACJ,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,UAAU,2BAA2B,CAAC,OAM3C;IACC,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC;QAAE,OAAO,KAAK,CAAC;IACjE,MAAM,UAAU,GAAG,iCAAiC,CAClD,OAAO,CAAC,SAAS,EACjB,OAAO,CAAC,QAAQ,CACjB,CAAC;IACF,IAAI,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,WAAW,CAAC;QAAE,OAAO,IAAI,CAAC;IAErD,OAAO,CACL,OAAO,CAAC,OAAO,KAAK,YAAY,IAAI,gBAAgB,CAAC,OAAO,CAAC,aAAa,CAAC,CAC5E,CAAC;AACJ,CAAC","sourcesContent":["const CHROME_EXTENSION_ID = /^[a-p]{32}$/;\nconst MAX_BROWSER_EXTENSION_IDS = 64;\n\nexport function resolveAllowedBrowserExtensionIds(\n configIds: readonly string[] = [],\n envValue = process.env.AGENT_NATIVE_BROWSER_EXTENSION_IDS,\n): Set<string> {\n const ids = [...configIds, ...(envValue?.split(\",\") ?? [])]\n .map((value) => value.trim())\n .filter(Boolean);\n const invalid = ids.find((value) => !CHROME_EXTENSION_ID.test(value));\n if (invalid) {\n throw new Error(\n \"Browser extension allowlist contains an invalid Chrome extension id.\",\n );\n }\n const uniqueIds = new Set(ids);\n if (uniqueIds.size > MAX_BROWSER_EXTENSION_IDS) {\n throw new Error(\n `Browser extension allowlist cannot exceed ${MAX_BROWSER_EXTENSION_IDS} ids.`,\n );\n }\n return uniqueIds;\n}\n\nfunction isLoopbackOrigin(value: string | undefined): boolean {\n if (!value) return false;\n try {\n const url = new URL(value);\n return (\n (url.protocol === \"http:\" || url.protocol === \"https:\") &&\n (url.hostname === \"localhost\" ||\n url.hostname === \"127.0.0.1\" ||\n url.hostname === \"[::1]\")\n );\n } catch {\n return false;\n }\n}\n\nexport function isBrowserExtensionIdAllowed(options: {\n extensionId: string;\n configIds?: readonly string[];\n envValue?: string;\n nodeEnv?: string;\n requestOrigin?: string;\n}): boolean {\n if (!CHROME_EXTENSION_ID.test(options.extensionId)) return false;\n const configured = resolveAllowedBrowserExtensionIds(\n options.configIds,\n options.envValue,\n );\n if (configured.has(options.extensionId)) return true;\n\n return (\n options.nodeEnv !== \"production\" && isLoopbackOrigin(options.requestOrigin)\n );\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/dispatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"tailwind-merge": "^3.5.0",
|
|
85
85
|
"vaul": "^1.1.2",
|
|
86
86
|
"zod": "^4.3.6",
|
|
87
|
-
"@agent-native/toolkit": "0.
|
|
87
|
+
"@agent-native/toolkit": "0.11.1"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
90
|
"@react-router/dev": "^8.1.0",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
99
99
|
"vite": "8.1.0",
|
|
100
100
|
"vitest": "^4.1.5",
|
|
101
|
-
"@agent-native/core": "0.
|
|
101
|
+
"@agent-native/core": "0.131.2"
|
|
102
102
|
},
|
|
103
103
|
"peerDependencies": {
|
|
104
104
|
"@agent-native/core": ">=0.8.0",
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const server = vi.hoisted(() => ({
|
|
4
|
+
buildStartPath: vi.fn(
|
|
5
|
+
(ticket: string) => `/_agent-native/embed/start?ticket=${ticket}`,
|
|
6
|
+
),
|
|
7
|
+
createTicket: vi.fn(),
|
|
8
|
+
getContext: vi.fn(),
|
|
9
|
+
getEmail: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
const dispatch = vi.hoisted(() => ({
|
|
12
|
+
getConfig: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
const integrations = vi.hoisted(() => ({
|
|
15
|
+
createRemoteDevice: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@agent-native/core/server", () => ({
|
|
19
|
+
buildEmbedStartPath: server.buildStartPath,
|
|
20
|
+
createEmbedSessionTicket: server.createTicket,
|
|
21
|
+
getRequestContext: server.getContext,
|
|
22
|
+
getRequestUserEmail: server.getEmail,
|
|
23
|
+
withConfiguredAppBasePath: (origin: string) => origin,
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@agent-native/core/integrations", () => ({
|
|
26
|
+
createRemoteDevice: integrations.createRemoteDevice,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("../server/index.js", () => ({
|
|
29
|
+
getDispatchConfig: dispatch.getConfig,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import action from "./create-browser-chat-session.js";
|
|
33
|
+
|
|
34
|
+
const nonce = "browser-chat-nonce-1234567890";
|
|
35
|
+
const extensionId = "abcdefghijklmnopabcdefghijklmnop";
|
|
36
|
+
const parentOrigin = `chrome-extension://${extensionId}`;
|
|
37
|
+
|
|
38
|
+
describe("create-browser-chat-session", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
server.createTicket.mockReset();
|
|
41
|
+
server.getContext.mockReturnValue({
|
|
42
|
+
orgId: "org-example",
|
|
43
|
+
requestOrigin: "https://dispatch.example.com",
|
|
44
|
+
});
|
|
45
|
+
server.getEmail.mockReturnValue("user@example.com");
|
|
46
|
+
dispatch.getConfig.mockReturnValue({
|
|
47
|
+
browserExtensionIds: [extensionId],
|
|
48
|
+
});
|
|
49
|
+
integrations.createRemoteDevice.mockResolvedValue({
|
|
50
|
+
device: { id: "remote-device-example" },
|
|
51
|
+
token: "anr_example",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("mints a one-time ticket bound to the bridge nonce and parent origin", async () => {
|
|
56
|
+
server.createTicket.mockResolvedValue({
|
|
57
|
+
ticket: "ticket-example",
|
|
58
|
+
ticketHash: "hash-example",
|
|
59
|
+
expiresAt: 12345,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await expect(action.run({ nonce, extensionId })).resolves.toEqual({
|
|
63
|
+
startPath: "/_agent-native/embed/start?ticket=ticket-example",
|
|
64
|
+
expiresAt: 12345,
|
|
65
|
+
parentOrigin,
|
|
66
|
+
remoteDevice: {
|
|
67
|
+
id: "remote-device-example",
|
|
68
|
+
token: "anr_example",
|
|
69
|
+
},
|
|
70
|
+
relayBaseUrl: "https://dispatch.example.com",
|
|
71
|
+
});
|
|
72
|
+
expect(server.createTicket).toHaveBeenCalledWith({
|
|
73
|
+
ownerEmail: "user@example.com",
|
|
74
|
+
orgId: "org-example",
|
|
75
|
+
targetPath:
|
|
76
|
+
"/browser-chat?browserChatNonce=browser-chat-nonce-1234567890&browserChatParentOrigin=chrome-extension%3A%2F%2Fabcdefghijklmnopabcdefghijklmnop",
|
|
77
|
+
scope: "browser-chat",
|
|
78
|
+
ttlSeconds: 60,
|
|
79
|
+
});
|
|
80
|
+
expect(action.agentTool).toBe(false);
|
|
81
|
+
expect(action.toolCallable).toBe(false);
|
|
82
|
+
expect(integrations.createRemoteDevice).toHaveBeenCalledWith({
|
|
83
|
+
ownerEmail: "user@example.com",
|
|
84
|
+
orgId: "org-example",
|
|
85
|
+
label: "Agent Native for Chrome",
|
|
86
|
+
platform: "chrome-extension",
|
|
87
|
+
metadata: {
|
|
88
|
+
browserExtension: { extensionId },
|
|
89
|
+
computerCapabilities: {
|
|
90
|
+
browser: {
|
|
91
|
+
observe: true,
|
|
92
|
+
control: true,
|
|
93
|
+
provider: "agent-native-chrome-extension",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("fails closed without an authenticated Dispatch identity", async () => {
|
|
101
|
+
server.getEmail.mockReturnValue(undefined);
|
|
102
|
+
|
|
103
|
+
await expect(action.run({ nonce, extensionId })).rejects.toThrow(
|
|
104
|
+
"Sign in to Dispatch",
|
|
105
|
+
);
|
|
106
|
+
expect(server.createTicket).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("rejects an extension id that is not configured in production", async () => {
|
|
110
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
111
|
+
process.env.NODE_ENV = "production";
|
|
112
|
+
dispatch.getConfig.mockReturnValue({ browserExtensionIds: [] });
|
|
113
|
+
try {
|
|
114
|
+
await expect(action.run({ nonce, extensionId })).rejects.toThrow(
|
|
115
|
+
"not allowed",
|
|
116
|
+
);
|
|
117
|
+
} finally {
|
|
118
|
+
process.env.NODE_ENV = previousNodeEnv;
|
|
119
|
+
}
|
|
120
|
+
expect(server.createTicket).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { createRemoteDevice } from "@agent-native/core/integrations";
|
|
3
|
+
import {
|
|
4
|
+
buildEmbedStartPath,
|
|
5
|
+
createEmbedSessionTicket,
|
|
6
|
+
getRequestContext,
|
|
7
|
+
getRequestUserEmail,
|
|
8
|
+
withConfiguredAppBasePath,
|
|
9
|
+
} from "@agent-native/core/server";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
BROWSER_CHAT_NONCE_QUERY_PARAM,
|
|
14
|
+
BROWSER_CHAT_PARENT_ORIGIN_QUERY_PARAM,
|
|
15
|
+
browserChatExtensionIdSchema,
|
|
16
|
+
browserChatNonceSchema,
|
|
17
|
+
} from "../lib/browser-chat-protocol.js";
|
|
18
|
+
import { isBrowserExtensionIdAllowed } from "../server/lib/browser-extension-allowlist.js";
|
|
19
|
+
|
|
20
|
+
export default defineAction({
|
|
21
|
+
description:
|
|
22
|
+
"Create a one-time authenticated Dispatch browser-chat embed session for an approved Agent-Native Chrome extension.",
|
|
23
|
+
schema: z
|
|
24
|
+
.object({
|
|
25
|
+
nonce: browserChatNonceSchema,
|
|
26
|
+
extensionId: browserChatExtensionIdSchema,
|
|
27
|
+
})
|
|
28
|
+
.strict(),
|
|
29
|
+
readOnly: false,
|
|
30
|
+
parallelSafe: true,
|
|
31
|
+
agentTool: false,
|
|
32
|
+
toolCallable: false,
|
|
33
|
+
run: async ({ nonce, extensionId }) => {
|
|
34
|
+
const ownerEmail = getRequestUserEmail()?.trim();
|
|
35
|
+
if (!ownerEmail) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"Sign in to Dispatch before connecting the browser extension.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const requestContext = getRequestContext();
|
|
42
|
+
const { browserExtensionIds } = await import("../server/index.js").then(
|
|
43
|
+
(module) => module.getDispatchConfig(),
|
|
44
|
+
);
|
|
45
|
+
if (
|
|
46
|
+
!isBrowserExtensionIdAllowed({
|
|
47
|
+
extensionId,
|
|
48
|
+
configIds: browserExtensionIds,
|
|
49
|
+
nodeEnv: process.env.NODE_ENV,
|
|
50
|
+
requestOrigin: requestContext?.requestOrigin,
|
|
51
|
+
})
|
|
52
|
+
) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
"This browser extension is not allowed to connect to Dispatch.",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parentOrigin = `chrome-extension://${extensionId}`;
|
|
59
|
+
const requestOrigin = requestContext?.requestOrigin;
|
|
60
|
+
if (!requestOrigin) {
|
|
61
|
+
throw new Error("Dispatch could not resolve its browser relay URL.");
|
|
62
|
+
}
|
|
63
|
+
const query = new URLSearchParams({
|
|
64
|
+
[BROWSER_CHAT_NONCE_QUERY_PARAM]: nonce,
|
|
65
|
+
[BROWSER_CHAT_PARENT_ORIGIN_QUERY_PARAM]: parentOrigin,
|
|
66
|
+
});
|
|
67
|
+
const session = await createEmbedSessionTicket({
|
|
68
|
+
ownerEmail,
|
|
69
|
+
orgId: requestContext?.orgId ?? null,
|
|
70
|
+
targetPath: `/browser-chat?${query}`,
|
|
71
|
+
scope: "browser-chat",
|
|
72
|
+
ttlSeconds: 60,
|
|
73
|
+
});
|
|
74
|
+
const remote = await createRemoteDevice({
|
|
75
|
+
ownerEmail,
|
|
76
|
+
orgId: requestContext?.orgId ?? null,
|
|
77
|
+
label: "Agent Native for Chrome",
|
|
78
|
+
platform: "chrome-extension",
|
|
79
|
+
metadata: {
|
|
80
|
+
browserExtension: { extensionId },
|
|
81
|
+
computerCapabilities: {
|
|
82
|
+
browser: {
|
|
83
|
+
observe: true,
|
|
84
|
+
control: true,
|
|
85
|
+
provider: "agent-native-chrome-extension",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
startPath: buildEmbedStartPath(session.ticket),
|
|
93
|
+
expiresAt: session.expiresAt,
|
|
94
|
+
parentOrigin,
|
|
95
|
+
remoteDevice: { id: remote.device.id, token: remote.token },
|
|
96
|
+
relayBaseUrl: withConfiguredAppBasePath(requestOrigin),
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
});
|
package/src/actions/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import approveVaultRequest from "./approve-vault-request.js";
|
|
|
6
6
|
import archiveWorkspaceApp from "./archive-workspace-app.js";
|
|
7
7
|
import askApp from "./ask_app.js";
|
|
8
8
|
import askAppStatus from "./ask_app_status.js";
|
|
9
|
+
import createBrowserChatSession from "./create-browser-chat-session.js";
|
|
9
10
|
import createDreamReport from "./create-dream-report.js";
|
|
10
11
|
import createLinkToken from "./create-link-token.js";
|
|
11
12
|
import createPylonTicket from "./create-pylon-ticket.js";
|
|
@@ -111,6 +112,7 @@ export const dispatchActions: Record<string, ActionEntry> = {
|
|
|
111
112
|
"create-workspace-resource-grant": createWorkspaceResourceGrant,
|
|
112
113
|
"create-workspace-resource": createWorkspaceResource,
|
|
113
114
|
"create-dream-report": createDreamReport,
|
|
115
|
+
"create-browser-chat-session": createBrowserChatSession,
|
|
114
116
|
create_embed_session: createEmbedSession,
|
|
115
117
|
"delete-staged-dataset": deleteStagedDataset,
|
|
116
118
|
"delete-destination": deleteDestination,
|
|
@@ -68,9 +68,12 @@ export default defineAction({
|
|
|
68
68
|
approvalPolicy: overview.settings,
|
|
69
69
|
};
|
|
70
70
|
if (navigation) screen.navigation = navigation;
|
|
71
|
-
if (navigation?.view === "chat") {
|
|
71
|
+
if (navigation?.view === "chat" || navigation?.view === "browser-chat") {
|
|
72
72
|
screen.chatSurface = {
|
|
73
|
-
view:
|
|
73
|
+
view:
|
|
74
|
+
navigation.view === "browser-chat"
|
|
75
|
+
? "embedded browser chat"
|
|
76
|
+
: "full-page Dispatch chat",
|
|
74
77
|
purpose:
|
|
75
78
|
"Create apps, manage workspace resources, route work to connected agents, and continue Dispatch conversations.",
|
|
76
79
|
};
|
|
@@ -246,4 +246,57 @@ describe("CreateAppFlow", () => {
|
|
|
246
246
|
expect(() => findButton(container, "Connect Builder")).toThrow();
|
|
247
247
|
expect(() => findButton(container, "Try again")).toThrow();
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
it("replaces the composer with Builder branch progress and success states", async () => {
|
|
251
|
+
let resolveBuilderRequest: ((response: Response) => void) | undefined;
|
|
252
|
+
fetchSpy.mockImplementation(async (input: RequestInfo | URL) => {
|
|
253
|
+
const url = String(input);
|
|
254
|
+
if (url.includes("get-vault-access-settings")) {
|
|
255
|
+
return jsonResponse({ mode: "all-apps" });
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
url.includes("list-vault-secret-options") ||
|
|
259
|
+
url.includes("list-workspace-resource-options")
|
|
260
|
+
) {
|
|
261
|
+
return jsonResponse([]);
|
|
262
|
+
}
|
|
263
|
+
if (url.includes("start-workspace-app-creation")) {
|
|
264
|
+
return new Promise<Response>((resolve) => {
|
|
265
|
+
resolveBuilderRequest = resolve;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return jsonResponse({ error: `Unexpected URL: ${url}` }, 404);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await renderAndSubmit("Build a quality dashboard");
|
|
272
|
+
|
|
273
|
+
await act(async () => {
|
|
274
|
+
await vi.waitFor(() => {
|
|
275
|
+
expect(container.textContent).toContain("Creating your Builder branch");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
expect(container.textContent).not.toContain("Dispatch keys");
|
|
279
|
+
|
|
280
|
+
await act(async () => {
|
|
281
|
+
resolveBuilderRequest?.(
|
|
282
|
+
jsonResponse({
|
|
283
|
+
mode: "builder",
|
|
284
|
+
appId: "quality-dashboard",
|
|
285
|
+
url: "https://branch.example.test",
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
await act(async () => {
|
|
291
|
+
await vi.waitFor(() => {
|
|
292
|
+
expect(container.textContent).toContain("Your Builder branch is ready");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
const branchLink = Array.from(container.querySelectorAll("a")).find(
|
|
296
|
+
(candidate) => candidate.textContent?.includes("Open Builder branch"),
|
|
297
|
+
);
|
|
298
|
+
expect(branchLink?.getAttribute("href")).toBe(
|
|
299
|
+
"https://branch.example.test",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
249
302
|
});
|
|
@@ -362,6 +362,55 @@ export function CreateAppFlow({
|
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
const submitWithSelectedAccess = () => submit(prompt);
|
|
365
|
+
const isCreatingBuilderBranch =
|
|
366
|
+
isSubmitting && !isInBuilderFrame() && !isDevMode;
|
|
367
|
+
|
|
368
|
+
if (branchUrl) {
|
|
369
|
+
return (
|
|
370
|
+
<div
|
|
371
|
+
className={`flex min-h-[260px] flex-col items-center justify-center gap-5 px-6 py-8 text-center ${className}`}
|
|
372
|
+
>
|
|
373
|
+
<span className="flex size-11 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
374
|
+
<IconCheck className="size-5" aria-hidden="true" />
|
|
375
|
+
</span>
|
|
376
|
+
<div className="space-y-2">
|
|
377
|
+
<h2 className="text-base font-semibold text-foreground">
|
|
378
|
+
Your Builder branch is ready
|
|
379
|
+
</h2>
|
|
380
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
381
|
+
Continue building and editing your app in Builder.
|
|
382
|
+
</p>
|
|
383
|
+
</div>
|
|
384
|
+
<Button asChild className="w-full sm:w-auto">
|
|
385
|
+
<a href={branchUrl} target="_blank" rel="noreferrer">
|
|
386
|
+
Open Builder branch <IconArrowUpRight aria-hidden="true" />
|
|
387
|
+
</a>
|
|
388
|
+
</Button>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isCreatingBuilderBranch) {
|
|
394
|
+
return (
|
|
395
|
+
<div
|
|
396
|
+
className={`flex min-h-[260px] flex-col items-center justify-center gap-4 px-6 py-8 text-center ${className}`}
|
|
397
|
+
aria-live="polite"
|
|
398
|
+
>
|
|
399
|
+
<IconLoader2
|
|
400
|
+
className="size-7 animate-spin text-muted-foreground"
|
|
401
|
+
aria-hidden="true"
|
|
402
|
+
/>
|
|
403
|
+
<div className="space-y-2">
|
|
404
|
+
<h2 className="text-base font-semibold text-foreground">
|
|
405
|
+
Creating your Builder branch
|
|
406
|
+
</h2>
|
|
407
|
+
<p className="text-sm leading-6 text-muted-foreground">
|
|
408
|
+
This usually takes a few seconds.
|
|
409
|
+
</p>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
365
414
|
|
|
366
415
|
return (
|
|
367
416
|
<div className={`flex flex-col gap-3 ${className}`}>
|
|
@@ -43,7 +43,6 @@ vi.mock("@agent-native/core/client/hooks", () => ({
|
|
|
43
43
|
}));
|
|
44
44
|
|
|
45
45
|
vi.mock("@agent-native/core/client/i18n", () => ({
|
|
46
|
-
LanguagePicker: () => <div>Language</div>,
|
|
47
46
|
useT: () => (key: string, values?: Record<string, unknown>) => {
|
|
48
47
|
const messages: Record<string, string> = {
|
|
49
48
|
"dispatch.nav.chat": "Chat",
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type ChatThreadSummary,
|
|
9
9
|
} from "@agent-native/core/client/agent-chat";
|
|
10
10
|
import { appBasePath, appPath } from "@agent-native/core/client/api-path";
|
|
11
|
-
import {
|
|
11
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
12
12
|
import { openCommandMenu } from "@agent-native/core/client/navigation";
|
|
13
13
|
import { InvitationBanner, OrgSwitcher } from "@agent-native/core/client/org";
|
|
14
14
|
import { FeedbackButton } from "@agent-native/core/client/ui";
|
|
@@ -229,7 +229,7 @@ const ADVANCED_NAV_ITEMS = [
|
|
|
229
229
|
|
|
230
230
|
const EMPTY_NAV_ITEMS: readonly DispatchNavItem[] = [];
|
|
231
231
|
|
|
232
|
-
const CHROMELESS_PATHS = ["/approval"];
|
|
232
|
+
const CHROMELESS_PATHS = ["/approval", "/browser-chat", "/browser-connect"];
|
|
233
233
|
const SIDEBAR_COLLAPSE_KEY = "dispatch.sidebar.collapsed";
|
|
234
234
|
|
|
235
235
|
// Routes whose page renders its own toolbar.
|
|
@@ -534,9 +534,6 @@ export function NavContent({
|
|
|
534
534
|
<TooltipContent side="right">{t("sidebar.search")}</TooltipContent>
|
|
535
535
|
</Tooltip>
|
|
536
536
|
);
|
|
537
|
-
const translateButton = (
|
|
538
|
-
<LanguagePicker variant="ghost-icon" label={t("settings.languageLabel")} />
|
|
539
|
-
);
|
|
540
537
|
const feedbackButton = (
|
|
541
538
|
<FeedbackButton
|
|
542
539
|
variant={collapsed ? "icon" : "sidebar"}
|
|
@@ -743,7 +740,6 @@ export function NavContent({
|
|
|
743
740
|
<SidebarFooterActions
|
|
744
741
|
collapsed={collapsed}
|
|
745
742
|
feedback={feedbackButton}
|
|
746
|
-
translate={translateButton}
|
|
747
743
|
search={searchButton}
|
|
748
744
|
collapse={collapseButton}
|
|
749
745
|
/>
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface DispatchIntegrationsConfig {
|
|
|
25
25
|
|
|
26
26
|
export interface DispatchConfig {
|
|
27
27
|
auth?: DispatchAuthConfig;
|
|
28
|
+
/**
|
|
29
|
+
* Exact Chrome Web Store or managed-install extension ids allowed to pair
|
|
30
|
+
* with Dispatch browser chat. Wildcards are not supported.
|
|
31
|
+
*/
|
|
32
|
+
browserExtensionIds?: readonly string[];
|
|
28
33
|
/**
|
|
29
34
|
* App IDs to hide from `list-connected-agents` results. Used to filter
|
|
30
35
|
* out first-party Builder apps (calls, issues, macros, …) from the
|
|
@@ -10,6 +10,13 @@ describe("buildDispatchNavigationState", () => {
|
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
it("recognizes the embedded browser chat route", () => {
|
|
14
|
+
expect(buildDispatchNavigationState("/browser-chat")).toEqual({
|
|
15
|
+
view: "browser-chat",
|
|
16
|
+
path: "/browser-chat",
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
it("exposes the current extension id from extension routes", () => {
|
|
14
21
|
expect(
|
|
15
22
|
buildDispatchNavigationState("/extensions/ext-1/github-stars-over-time"),
|
|
@@ -208,6 +208,7 @@ function resolveView(
|
|
|
208
208
|
if (pathname === "/extensions" || pathname.startsWith("/extensions/")) {
|
|
209
209
|
return "extensions";
|
|
210
210
|
}
|
|
211
|
+
if (pathname.startsWith("/browser-chat")) return "browser-chat";
|
|
211
212
|
if (pathname.startsWith("/chat")) return "chat";
|
|
212
213
|
if (pathname.startsWith("/apps")) return "apps";
|
|
213
214
|
if (pathname.startsWith("/metrics")) return "metrics";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { listDispatchAutomations } from "./automations.js";
|
|
4
|
+
|
|
5
|
+
describe("listDispatchAutomations", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns the automation list", async () => {
|
|
11
|
+
vi.stubGlobal(
|
|
12
|
+
"fetch",
|
|
13
|
+
vi.fn().mockResolvedValue(
|
|
14
|
+
new Response(
|
|
15
|
+
JSON.stringify([
|
|
16
|
+
{
|
|
17
|
+
id: "automation-1",
|
|
18
|
+
name: "daily-digest",
|
|
19
|
+
path: "jobs/daily-digest.md",
|
|
20
|
+
owner: "alice@example.com",
|
|
21
|
+
},
|
|
22
|
+
]),
|
|
23
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
24
|
+
),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
await expect(listDispatchAutomations()).resolves.toEqual([
|
|
29
|
+
expect.objectContaining({ name: "daily-digest" }),
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("surfaces request failures instead of presenting an empty list", async () => {
|
|
34
|
+
vi.stubGlobal(
|
|
35
|
+
"fetch",
|
|
36
|
+
vi.fn().mockResolvedValue(
|
|
37
|
+
new Response(JSON.stringify({ error: "Database unavailable" }), {
|
|
38
|
+
status: 503,
|
|
39
|
+
headers: { "content-type": "application/json" },
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await expect(listDispatchAutomations()).rejects.toThrow(
|
|
45
|
+
"Database unavailable",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects malformed successful responses", async () => {
|
|
50
|
+
vi.stubGlobal(
|
|
51
|
+
"fetch",
|
|
52
|
+
vi.fn().mockResolvedValue(
|
|
53
|
+
new Response(JSON.stringify({ automations: [] }), {
|
|
54
|
+
status: 200,
|
|
55
|
+
headers: { "content-type": "application/json" },
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await expect(listDispatchAutomations()).rejects.toThrow(
|
|
61
|
+
"Automation list returned an invalid response",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/lib/automations.ts
CHANGED
|
@@ -43,9 +43,11 @@ export async function listDispatchAutomations(): Promise<
|
|
|
43
43
|
DispatchAutomationItem[]
|
|
44
44
|
> {
|
|
45
45
|
const response = await fetch(agentNativePath("/_agent-native/automations"));
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
const rows = await readAutomationResponse<unknown>(response);
|
|
47
|
+
if (!Array.isArray(rows)) {
|
|
48
|
+
throw new Error("Automation list returned an invalid response");
|
|
49
|
+
}
|
|
50
|
+
return rows as DispatchAutomationItem[];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export async function setDispatchAutomationEnabled(
|