@agent-native/dispatch 0.27.2 → 0.27.4
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-workspace-resource.d.ts +13 -13
- package/dist/actions/delete-destination.d.ts +12 -12
- package/dist/actions/import-agent.d.ts +13 -13
- package/dist/actions/provider-api-register.d.ts +9 -9
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +80 -5
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
- package/dist/server/lib/mcp-gateway.js +42 -10
- package/dist/server/lib/mcp-gateway.js.map +1 -1
- package/dist/server/lib/workspace-app-action-auth.d.ts +9 -0
- package/dist/server/lib/workspace-app-action-auth.d.ts.map +1 -0
- package/dist/server/lib/workspace-app-action-auth.js +71 -0
- package/dist/server/lib/workspace-app-action-auth.js.map +1 -0
- package/dist/server/plugins/agent-chat.js +3 -0
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/server/lib/app-creation-store.spec.ts +57 -0
- package/src/server/lib/app-creation-store.ts +92 -9
- package/src/server/lib/mcp-gateway.spec.ts +49 -0
- package/src/server/lib/mcp-gateway.ts +34 -5
- package/src/server/lib/workspace-app-action-auth.spec.ts +173 -0
- package/src/server/lib/workspace-app-action-auth.ts +90 -0
- package/src/server/plugins/agent-chat.spec.ts +4 -0
- package/src/server/plugins/agent-chat.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/dispatch",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.4",
|
|
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": {
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"tailwind-merge": "^3.5.0",
|
|
88
88
|
"vaul": "^1.1.2",
|
|
89
89
|
"zod": "^4.3.6",
|
|
90
|
-
"@agent-native/toolkit": "0.16.
|
|
90
|
+
"@agent-native/toolkit": "0.16.3"
|
|
91
91
|
},
|
|
92
92
|
"devDependencies": {
|
|
93
93
|
"@react-router/dev": "^8.1.0",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
102
102
|
"vite": "8.1.0",
|
|
103
103
|
"vitest": "^4.1.5",
|
|
104
|
-
"@agent-native/core": "0.157.
|
|
104
|
+
"@agent-native/core": "0.157.15"
|
|
105
105
|
},
|
|
106
106
|
"peerDependencies": {
|
|
107
107
|
"@agent-native/core": ">=0.8.0",
|
|
@@ -259,6 +259,63 @@ describe("listWorkspaceApps", () => {
|
|
|
259
259
|
]);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("falls back to the authenticated workspace action for hosted gateways", async () => {
|
|
263
|
+
const fetchMock = vi
|
|
264
|
+
.fn()
|
|
265
|
+
.mockResolvedValueOnce(new Response("not found", { status: 404 }))
|
|
266
|
+
.mockResolvedValueOnce(
|
|
267
|
+
new Response(
|
|
268
|
+
JSON.stringify([
|
|
269
|
+
{
|
|
270
|
+
id: "atlas",
|
|
271
|
+
name: "Atlas",
|
|
272
|
+
path: "/atlas",
|
|
273
|
+
url: "https://agent-workspace.builder.io/atlas",
|
|
274
|
+
},
|
|
275
|
+
]),
|
|
276
|
+
{ headers: { "content-type": "application/json" } },
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
280
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
281
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
282
|
+
|
|
283
|
+
const apps = await runWithRequestContext(
|
|
284
|
+
{ userEmail: "dev@example.test" },
|
|
285
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
289
|
+
expect(String(fetchMock.mock.calls[1]?.[0])).toBe(
|
|
290
|
+
"https://agent-workspace.builder.io/_agent-native/actions/list-workspace-apps?includeAgentCards=false&audience=all",
|
|
291
|
+
);
|
|
292
|
+
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
293
|
+
expect.objectContaining({
|
|
294
|
+
headers: expect.objectContaining({
|
|
295
|
+
accept: "application/json",
|
|
296
|
+
Authorization: expect.stringMatching(/^Bearer /),
|
|
297
|
+
}),
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
expect(apps.map((app) => app.id)).toEqual(["atlas"]);
|
|
301
|
+
expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("falls back to local discovery when the gateway URL is malformed", async () => {
|
|
305
|
+
stubManifest();
|
|
306
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "not-a-url");
|
|
307
|
+
const fetchMock = vi.fn();
|
|
308
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
309
|
+
|
|
310
|
+
const apps = await runWithRequestContext(
|
|
311
|
+
{ userEmail: "dev@example.test" },
|
|
312
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
316
|
+
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
|
|
317
|
+
});
|
|
318
|
+
|
|
262
319
|
it("filters workspace apps by audience", async () => {
|
|
263
320
|
stubNoPendingContext();
|
|
264
321
|
vi.stubEnv(
|
|
@@ -3,7 +3,9 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
+
import { signA2AToken } from "@agent-native/core/a2a";
|
|
6
7
|
import { getDbExec } from "@agent-native/core/db";
|
|
8
|
+
import { getOrgA2ASecret, getOrgDomain } from "@agent-native/core/org";
|
|
7
9
|
import {
|
|
8
10
|
ensureBuilderProject,
|
|
9
11
|
getBuilderBranchProjectId,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
resolveLinkedOwner,
|
|
24
26
|
} from "./dispatch-store.js";
|
|
25
27
|
import { createRequest, listSecretOptions } from "./vault-store.js";
|
|
28
|
+
import { WORKSPACE_APPS_ACTION_PATH } from "./workspace-app-action-auth.js";
|
|
26
29
|
import {
|
|
27
30
|
grantWorkspaceResourcesToApp,
|
|
28
31
|
listWorkspaceResourceOptions,
|
|
@@ -42,7 +45,7 @@ const WORKSPACE_APP_METADATA_SETTINGS_KEY = "workspace-app-metadata";
|
|
|
42
45
|
const WORKSPACE_APPS_ENV_KEY = "AGENT_NATIVE_WORKSPACE_APPS_JSON";
|
|
43
46
|
const WORKSPACE_APPS_MANIFEST_FILE = "workspace-apps.json";
|
|
44
47
|
const WORKSPACE_APPS_GATEWAY_PATH = "/_workspace/apps";
|
|
45
|
-
const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS =
|
|
48
|
+
const WORKSPACE_APPS_GATEWAY_TIMEOUT_MS = 2_500;
|
|
46
49
|
const MAX_PENDING_APPS = 50;
|
|
47
50
|
const PENDING_WORKSPACE_APP_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
48
51
|
const AGENT_CARD_PATH = "/.well-known/agent-card.json";
|
|
@@ -1075,22 +1078,102 @@ async function readWorkspaceAppsFromGateway(): Promise<
|
|
|
1075
1078
|
const base = process.env.WORKSPACE_GATEWAY_URL;
|
|
1076
1079
|
if (!base) return null;
|
|
1077
1080
|
|
|
1081
|
+
let baseUrl: URL;
|
|
1082
|
+
try {
|
|
1083
|
+
baseUrl = new URL(base);
|
|
1084
|
+
} catch {
|
|
1085
|
+
// coercion-ok: malformed gateway configuration is an unavailable registry
|
|
1086
|
+
// and falls back to local sources.
|
|
1087
|
+
return null;
|
|
1088
|
+
}
|
|
1089
|
+
if (baseUrl.protocol !== "http:" && baseUrl.protocol !== "https:") {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1078
1093
|
const controller = new AbortController();
|
|
1079
1094
|
const timeout = setTimeout(
|
|
1080
1095
|
() => controller.abort(),
|
|
1081
1096
|
WORKSPACE_APPS_GATEWAY_TIMEOUT_MS,
|
|
1082
1097
|
);
|
|
1083
1098
|
|
|
1099
|
+
const requestContext = getRequestContext();
|
|
1100
|
+
const authHeaders: Record<string, string> = {};
|
|
1101
|
+
if (requestContext?.userEmail) {
|
|
1102
|
+
const [orgDomain, orgSecret] = requestContext.orgId
|
|
1103
|
+
? await Promise.all([
|
|
1104
|
+
// coercion-ok: an unavailable org row falls back to the deployment secret.
|
|
1105
|
+
getOrgDomain(requestContext.orgId).catch(() => null),
|
|
1106
|
+
// coercion-ok: an unavailable org row falls back to the deployment secret.
|
|
1107
|
+
getOrgA2ASecret(requestContext.orgId).catch(() => null),
|
|
1108
|
+
])
|
|
1109
|
+
: [null, null];
|
|
1110
|
+
const usableOrgSecret =
|
|
1111
|
+
typeof orgSecret === "string" && orgSecret.trim().length > 0;
|
|
1112
|
+
const usableOrgDomain =
|
|
1113
|
+
typeof orgDomain === "string" && orgDomain.trim().length > 0;
|
|
1114
|
+
try {
|
|
1115
|
+
const token = await signA2AToken(
|
|
1116
|
+
requestContext.userEmail,
|
|
1117
|
+
usableOrgDomain ? orgDomain.trim() : undefined,
|
|
1118
|
+
usableOrgSecret ? orgSecret.trim() : undefined,
|
|
1119
|
+
{
|
|
1120
|
+
expiresIn: "1m",
|
|
1121
|
+
// The workspace registry is an authenticated read. Prefer the
|
|
1122
|
+
// shared deployment secret so a registry read does not depend on
|
|
1123
|
+
// every app having the same org row.
|
|
1124
|
+
preferGlobalSecret: true,
|
|
1125
|
+
},
|
|
1126
|
+
);
|
|
1127
|
+
authHeaders.Authorization = `Bearer ${token}`;
|
|
1128
|
+
} catch {
|
|
1129
|
+
// coercion-ok: absent signing credentials keep local unauthenticated
|
|
1130
|
+
// gateway discovery available and make hosted discovery fail closed.
|
|
1131
|
+
// Keep the unauthenticated local-dev gateway path available. A hosted
|
|
1132
|
+
// gateway will fail closed below when its action route needs identity.
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const gatewayUrl = (pathname: string): URL => {
|
|
1137
|
+
const url = new URL(baseUrl.toString());
|
|
1138
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
1139
|
+
url.pathname = `${basePath}${pathname}` || "/";
|
|
1140
|
+
url.search = "";
|
|
1141
|
+
url.hash = "";
|
|
1142
|
+
return url;
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
const headers = {
|
|
1146
|
+
accept: "application/json",
|
|
1147
|
+
...authHeaders,
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1084
1150
|
try {
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1151
|
+
const localResponse = await fetch(gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH), {
|
|
1152
|
+
headers,
|
|
1153
|
+
signal: controller.signal,
|
|
1154
|
+
});
|
|
1155
|
+
if (localResponse.ok) {
|
|
1156
|
+
return parseWorkspaceAppsManifest(
|
|
1157
|
+
// coercion-ok: malformed gateway JSON is an unavailable registry and
|
|
1158
|
+
// must fall through to the local manifest sources.
|
|
1159
|
+
await localResponse.json().catch(() => null),
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (!authHeaders.Authorization) return null;
|
|
1164
|
+
const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
|
|
1165
|
+
actionUrl.searchParams.set("includeAgentCards", "false");
|
|
1166
|
+
actionUrl.searchParams.set("audience", "all");
|
|
1167
|
+
const actionResponse = await fetch(actionUrl, {
|
|
1168
|
+
headers,
|
|
1169
|
+
signal: controller.signal,
|
|
1170
|
+
});
|
|
1171
|
+
if (!actionResponse.ok) return null;
|
|
1172
|
+
return parseWorkspaceAppsManifest(
|
|
1173
|
+
// coercion-ok: malformed gateway JSON is an unavailable registry and
|
|
1174
|
+
// must fall through to the local manifest sources.
|
|
1175
|
+
await actionResponse.json().catch(() => null),
|
|
1091
1176
|
);
|
|
1092
|
-
if (!response.ok) return null;
|
|
1093
|
-
return parseWorkspaceAppsManifest(await response.json().catch(() => null));
|
|
1094
1177
|
} catch {
|
|
1095
1178
|
return null;
|
|
1096
1179
|
} finally {
|
|
@@ -940,6 +940,55 @@ describe("createGrantedDispatchMcpEmbedSession", () => {
|
|
|
940
940
|
expect(mocks.managerConstructor).toHaveBeenCalled();
|
|
941
941
|
});
|
|
942
942
|
|
|
943
|
+
it("allows custom mounted workspace apps from the live workspace registry", async () => {
|
|
944
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
945
|
+
mocks.discoverAgents.mockResolvedValue([]);
|
|
946
|
+
mocks.managerCallTool.mockResolvedValueOnce({
|
|
947
|
+
structuredContent: {
|
|
948
|
+
startUrl:
|
|
949
|
+
"https://agent-workspace.builder.io/atlas/_agent-native/embed/start?ticket=remote",
|
|
950
|
+
},
|
|
951
|
+
});
|
|
952
|
+
mocks.listWorkspaceApps.mockResolvedValue([
|
|
953
|
+
{
|
|
954
|
+
id: "atlas",
|
|
955
|
+
name: "Atlas",
|
|
956
|
+
description: "Workspace app",
|
|
957
|
+
path: "/atlas",
|
|
958
|
+
url: "https://agent-workspace.builder.io/atlas",
|
|
959
|
+
isDispatch: false,
|
|
960
|
+
audience: "internal",
|
|
961
|
+
publicPaths: [],
|
|
962
|
+
protectedPaths: [],
|
|
963
|
+
},
|
|
964
|
+
]);
|
|
965
|
+
|
|
966
|
+
const result = await runWithRequestContext(
|
|
967
|
+
{
|
|
968
|
+
userEmail: "owner@example.test",
|
|
969
|
+
requestOrigin: "https://dispatch.agent-native.com",
|
|
970
|
+
},
|
|
971
|
+
() =>
|
|
972
|
+
createWorkspaceSsoEmbedSession({
|
|
973
|
+
app: "atlas",
|
|
974
|
+
path: "/",
|
|
975
|
+
}),
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
expect(result).toMatchObject({
|
|
979
|
+
app: "atlas",
|
|
980
|
+
startUrl:
|
|
981
|
+
"https://agent-workspace.builder.io/atlas/_agent-native/embed/start?ticket=remote",
|
|
982
|
+
});
|
|
983
|
+
expect(mocks.managerConstructor).toHaveBeenCalledWith({
|
|
984
|
+
servers: {
|
|
985
|
+
target: expect.objectContaining({
|
|
986
|
+
url: "https://agent-workspace.builder.io/atlas/mcp",
|
|
987
|
+
}),
|
|
988
|
+
},
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
943
992
|
it("allows an exact custom registry entry and rejects an unregistered external app", async () => {
|
|
944
993
|
vi.stubEnv(
|
|
945
994
|
"IDENTITY_SSO_APP_REGISTRY_JSON",
|
|
@@ -621,6 +621,17 @@ function workspaceSsoOriginForApp(appId: string): string | null {
|
|
|
621
621
|
return custom?.origin ?? null;
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
+
function workspaceSsoGatewayOrigin(): string | null {
|
|
625
|
+
const configured = process.env.WORKSPACE_GATEWAY_URL?.trim();
|
|
626
|
+
if (!configured) return null;
|
|
627
|
+
try {
|
|
628
|
+
return new URL(configured).origin;
|
|
629
|
+
} catch {
|
|
630
|
+
// coercion-ok: malformed deployment configuration is not a trusted origin.
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
624
635
|
function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
|
|
625
636
|
const origin = safeAppOrigin(app);
|
|
626
637
|
if (!origin) return false;
|
|
@@ -664,7 +675,8 @@ async function isEligibleWorkspaceSsoApp(
|
|
|
664
675
|
color: DISPATCH_COLOR,
|
|
665
676
|
granted: true,
|
|
666
677
|
});
|
|
667
|
-
|
|
678
|
+
const trustedMountedOrigin = workspaceSsoGatewayOrigin() ?? dispatchOrigin;
|
|
679
|
+
if (origin !== trustedMountedOrigin) return false;
|
|
668
680
|
return mountedApps.some((mounted) => sameWorkspaceApp(candidate, mounted));
|
|
669
681
|
}
|
|
670
682
|
|
|
@@ -673,15 +685,32 @@ async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
|
|
|
673
685
|
discoverAgents("dispatch"),
|
|
674
686
|
listWorkspaceApps({ includeAgentCards: false }),
|
|
675
687
|
]);
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
688
|
+
const candidatesById = new Map<string, DispatchMcpAccessibleApp>();
|
|
689
|
+
for (const agent of agents) {
|
|
690
|
+
if (normalizeAppId(agent.id) === DISPATCH_APP_ID) continue;
|
|
691
|
+
candidatesById.set(agent.id, {
|
|
679
692
|
...toAccessibleApp(agent, {
|
|
680
693
|
mode: "all-apps",
|
|
681
694
|
selectedAppIds: [],
|
|
682
695
|
}),
|
|
683
696
|
granted: true,
|
|
684
|
-
})
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
// The hosted Dispatch database is separate from the shared Workspace
|
|
700
|
+
// database. Overlay the live mounted registry so custom apps are eligible
|
|
701
|
+
// without copying a stale app list into Dispatch configuration.
|
|
702
|
+
for (const app of mountedApps) {
|
|
703
|
+
if (app.isDispatch || !app.url) continue;
|
|
704
|
+
candidatesById.set(app.id, {
|
|
705
|
+
id: app.id,
|
|
706
|
+
name: app.name,
|
|
707
|
+
description: app.description,
|
|
708
|
+
url: app.url,
|
|
709
|
+
color: DISPATCH_COLOR,
|
|
710
|
+
granted: true,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
const candidates = [...candidatesById.values()];
|
|
685
714
|
const eligible = [] as DispatchMcpAccessibleApp[];
|
|
686
715
|
for (const candidate of candidates) {
|
|
687
716
|
if (await isEligibleWorkspaceSsoApp(candidate, mountedApps)) {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
verifyA2AToken: vi.fn(),
|
|
5
|
+
isOrgMember: vi.fn(),
|
|
6
|
+
resolveOrgByDomain: vi.fn(),
|
|
7
|
+
resolveOrgIdForEmail: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("@agent-native/core/a2a", () => ({
|
|
11
|
+
verifyA2AToken: mocks.verifyA2AToken,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@agent-native/core/org", () => ({
|
|
15
|
+
isOrgMember: mocks.isOrgMember,
|
|
16
|
+
resolveOrgByDomain: mocks.resolveOrgByDomain,
|
|
17
|
+
resolveOrgIdForEmail: mocks.resolveOrgIdForEmail,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
workspaceAppActionRouteAuth,
|
|
22
|
+
WORKSPACE_APPS_ACTION_PATH,
|
|
23
|
+
} from "./workspace-app-action-auth.js";
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function eventFor(path: string, authorization?: string) {
|
|
30
|
+
return {
|
|
31
|
+
path,
|
|
32
|
+
context: {},
|
|
33
|
+
node: {
|
|
34
|
+
req: {
|
|
35
|
+
headers: authorization ? { authorization } : {},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("workspace app action auth", () => {
|
|
42
|
+
it("only resolves the mounted workspace registry action", async () => {
|
|
43
|
+
await expect(
|
|
44
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
45
|
+
eventFor(
|
|
46
|
+
"/_agent-native/actions/list-connected-agents",
|
|
47
|
+
"Bearer token",
|
|
48
|
+
),
|
|
49
|
+
),
|
|
50
|
+
).resolves.toBeNull();
|
|
51
|
+
expect(mocks.verifyA2AToken).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("rejects an invalid registry bearer instead of falling through to cookies", async () => {
|
|
55
|
+
mocks.verifyA2AToken.mockResolvedValue({ email: null, orgDomain: null });
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
59
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer invalid"),
|
|
60
|
+
),
|
|
61
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns the verified caller and local org scope", async () => {
|
|
65
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
66
|
+
email: "steve@builder.io",
|
|
67
|
+
orgDomain: "builder.io",
|
|
68
|
+
});
|
|
69
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
70
|
+
orgId: "org-builder",
|
|
71
|
+
orgName: "Builder.io",
|
|
72
|
+
});
|
|
73
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
77
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer verified"),
|
|
78
|
+
),
|
|
79
|
+
).resolves.toEqual({
|
|
80
|
+
owner: "steve@builder.io",
|
|
81
|
+
anonymous: false,
|
|
82
|
+
orgId: "org-builder",
|
|
83
|
+
});
|
|
84
|
+
expect(mocks.verifyA2AToken).toHaveBeenCalledWith(
|
|
85
|
+
"verified",
|
|
86
|
+
expect.anything(),
|
|
87
|
+
);
|
|
88
|
+
expect(mocks.resolveOrgByDomain).toHaveBeenCalledWith("builder.io");
|
|
89
|
+
expect(mocks.isOrgMember).toHaveBeenCalledWith(
|
|
90
|
+
"org-builder",
|
|
91
|
+
"steve@builder.io",
|
|
92
|
+
);
|
|
93
|
+
expect(mocks.resolveOrgIdForEmail).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses the original mounted pathname after the route prefix is stripped", async () => {
|
|
97
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
98
|
+
email: "steve@builder.io",
|
|
99
|
+
orgDomain: "builder.io",
|
|
100
|
+
});
|
|
101
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
102
|
+
orgId: "org-builder",
|
|
103
|
+
orgName: "Builder.io",
|
|
104
|
+
});
|
|
105
|
+
mocks.isOrgMember.mockResolvedValue(true);
|
|
106
|
+
const event = eventFor("/", "Bearer mounted");
|
|
107
|
+
event.context = {
|
|
108
|
+
_mountedPathname: WORKSPACE_APPS_ACTION_PATH,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
await expect(
|
|
112
|
+
workspaceAppActionRouteAuth.resolveCaller?.(event),
|
|
113
|
+
).resolves.toMatchObject({
|
|
114
|
+
owner: "steve@builder.io",
|
|
115
|
+
orgId: "org-builder",
|
|
116
|
+
});
|
|
117
|
+
expect(mocks.verifyA2AToken).toHaveBeenCalledWith(
|
|
118
|
+
"mounted",
|
|
119
|
+
expect.anything(),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps legacy email-derived scope when the token has no org domain", async () => {
|
|
124
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
125
|
+
email: "steve@builder.io",
|
|
126
|
+
orgDomain: null,
|
|
127
|
+
});
|
|
128
|
+
mocks.resolveOrgIdForEmail.mockResolvedValue("org-by-email");
|
|
129
|
+
|
|
130
|
+
await expect(
|
|
131
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
132
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer legacy"),
|
|
133
|
+
),
|
|
134
|
+
).resolves.toMatchObject({ orgId: "org-by-email" });
|
|
135
|
+
expect(mocks.resolveOrgIdForEmail).toHaveBeenCalledWith("steve@builder.io");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("rejects a verified domain that is not registered locally", async () => {
|
|
139
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
140
|
+
email: "steve@builder.io",
|
|
141
|
+
orgDomain: "unknown.example",
|
|
142
|
+
});
|
|
143
|
+
mocks.resolveOrgByDomain.mockResolvedValue(null);
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
147
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer unmapped"),
|
|
148
|
+
),
|
|
149
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("rejects a global-token caller who is not a member of the claimed org", async () => {
|
|
153
|
+
mocks.verifyA2AToken.mockResolvedValue({
|
|
154
|
+
email: "outsider@example.com",
|
|
155
|
+
orgDomain: "builder.io",
|
|
156
|
+
});
|
|
157
|
+
mocks.resolveOrgByDomain.mockResolvedValue({
|
|
158
|
+
orgId: "org-builder",
|
|
159
|
+
orgName: "Builder.io",
|
|
160
|
+
});
|
|
161
|
+
mocks.isOrgMember.mockResolvedValue(false);
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
workspaceAppActionRouteAuth.resolveCaller?.(
|
|
165
|
+
eventFor(WORKSPACE_APPS_ACTION_PATH, "Bearer global-token"),
|
|
166
|
+
),
|
|
167
|
+
).rejects.toThrow("Invalid workspace registry authorization");
|
|
168
|
+
expect(mocks.isOrgMember).toHaveBeenCalledWith(
|
|
169
|
+
"org-builder",
|
|
170
|
+
"outsider@example.com",
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { verifyA2AToken } from "@agent-native/core/a2a";
|
|
2
|
+
import {
|
|
3
|
+
isOrgMember,
|
|
4
|
+
resolveOrgByDomain,
|
|
5
|
+
resolveOrgIdForEmail,
|
|
6
|
+
} from "@agent-native/core/org";
|
|
7
|
+
import type {
|
|
8
|
+
ActionRouteAuthAdapter,
|
|
9
|
+
ActionRouteResolvedCaller,
|
|
10
|
+
} from "@agent-native/core/server";
|
|
11
|
+
|
|
12
|
+
export const WORKSPACE_APPS_ACTION_PATH =
|
|
13
|
+
"/_agent-native/actions/list-workspace-apps";
|
|
14
|
+
|
|
15
|
+
function isWorkspaceAppsActionPath(event: any): boolean {
|
|
16
|
+
const rawUrl =
|
|
17
|
+
(typeof event?.context?._mountedPathname === "string" &&
|
|
18
|
+
event.context._mountedPathname) ||
|
|
19
|
+
(typeof event?.path === "string" && event.path) ||
|
|
20
|
+
(typeof event?.url?.pathname === "string" && event.url.pathname) ||
|
|
21
|
+
(typeof event?.node?.req?.url === "string" && event.node.req.url) ||
|
|
22
|
+
(typeof event?.req?.url === "string" && event.req.url) ||
|
|
23
|
+
"/";
|
|
24
|
+
const requestPath =
|
|
25
|
+
String(rawUrl).split("?", 1)[0].replace(/\/+$/, "") || "/";
|
|
26
|
+
const appBasePath = process.env.APP_BASE_PATH?.trim().replace(/\/+$/, "");
|
|
27
|
+
return (
|
|
28
|
+
requestPath === WORKSPACE_APPS_ACTION_PATH ||
|
|
29
|
+
Boolean(
|
|
30
|
+
appBasePath &&
|
|
31
|
+
`${appBasePath}${WORKSPACE_APPS_ACTION_PATH}` === requestPath,
|
|
32
|
+
)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readAuthorizationHeader(event: any): string | undefined {
|
|
37
|
+
const headers = event?.node?.req?.headers ?? event?.req?.headers;
|
|
38
|
+
if (typeof headers?.get === "function") {
|
|
39
|
+
return headers.get("authorization") ?? undefined;
|
|
40
|
+
}
|
|
41
|
+
const value = headers?.authorization ?? headers?.Authorization;
|
|
42
|
+
return Array.isArray(value) ? value[0] : value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The hosted workspace registry is a read-only action that accepts a caller
|
|
47
|
+
* JWT from Dispatch. Keep this resolver path-scoped so the shared secret never
|
|
48
|
+
* becomes a bearer credential for unrelated Dispatch actions.
|
|
49
|
+
*/
|
|
50
|
+
export const workspaceAppActionRouteAuth: ActionRouteAuthAdapter = {
|
|
51
|
+
resolveCaller: async (event): Promise<ActionRouteResolvedCaller | null> => {
|
|
52
|
+
if (!isWorkspaceAppsActionPath(event)) return null;
|
|
53
|
+
|
|
54
|
+
const authorization = readAuthorizationHeader(event);
|
|
55
|
+
if (!authorization) return null;
|
|
56
|
+
if (!authorization.startsWith("Bearer ")) {
|
|
57
|
+
throw new Error("Invalid workspace registry authorization");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = authorization.slice("Bearer ".length).trim();
|
|
61
|
+
if (!token) throw new Error("Invalid workspace registry authorization");
|
|
62
|
+
|
|
63
|
+
const identity = await verifyA2AToken(token, event);
|
|
64
|
+
if (!identity.email) {
|
|
65
|
+
throw new Error("Invalid workspace registry authorization");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const orgDomain = identity.orgDomain?.trim().toLowerCase();
|
|
69
|
+
let orgId: string | null;
|
|
70
|
+
if (orgDomain) {
|
|
71
|
+
// A verified domain claim identifies the caller's intended org. Resolve
|
|
72
|
+
// it locally instead of falling back to the receiver's active-org or
|
|
73
|
+
// first-membership selection, which can be wrong for multi-org users.
|
|
74
|
+
const org = await resolveOrgByDomain(orgDomain);
|
|
75
|
+
if (!org || !(await isOrgMember(org.orgId, identity.email))) {
|
|
76
|
+
throw new Error("Invalid workspace registry authorization");
|
|
77
|
+
}
|
|
78
|
+
orgId = org.orgId;
|
|
79
|
+
} else {
|
|
80
|
+
// Preserve compatibility with legacy tokens that predate org_domain.
|
|
81
|
+
orgId = await resolveOrgIdForEmail(identity.email);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
owner: identity.email,
|
|
86
|
+
anonymous: false,
|
|
87
|
+
orgId,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -33,6 +33,10 @@ describe("Dispatch agent chat plugin", () => {
|
|
|
33
33
|
"start-workspace-app-creation",
|
|
34
34
|
]),
|
|
35
35
|
},
|
|
36
|
+
actionRoutePublicPaths: ["/_agent-native/actions/list-workspace-apps"],
|
|
37
|
+
actionRouteAuth: expect.objectContaining({
|
|
38
|
+
resolveCaller: expect.any(Function),
|
|
39
|
+
}),
|
|
36
40
|
}),
|
|
37
41
|
);
|
|
38
42
|
});
|
|
@@ -2,6 +2,10 @@ import { getOrgContext } from "@agent-native/core/org";
|
|
|
2
2
|
import { createAgentChatPlugin } from "@agent-native/core/server";
|
|
3
3
|
|
|
4
4
|
import { dispatchActions } from "../../actions/index.js";
|
|
5
|
+
import {
|
|
6
|
+
workspaceAppActionRouteAuth,
|
|
7
|
+
WORKSPACE_APPS_ACTION_PATH,
|
|
8
|
+
} from "../lib/workspace-app-action-auth.js";
|
|
5
9
|
|
|
6
10
|
const INITIAL_TOOL_NAMES = [
|
|
7
11
|
"view-screen",
|
|
@@ -53,6 +57,8 @@ export default createAgentChatPlugin({
|
|
|
53
57
|
// a build-time-generated `.generated/actions-registry.ts` (the latter is a
|
|
54
58
|
// template-only construct that the Vite plugin emits next to actions/).
|
|
55
59
|
actions: dispatchActions,
|
|
60
|
+
actionRouteAuth: workspaceAppActionRouteAuth,
|
|
61
|
+
actionRoutePublicPaths: [WORKSPACE_APPS_ACTION_PATH],
|
|
56
62
|
codeExecution: { production: "sandboxed" },
|
|
57
63
|
systemPrompt: `You are the central dispatch for this workspace.
|
|
58
64
|
|