@agent-native/dispatch 0.27.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.27.3",
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": {
@@ -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.10"
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 = 1_000;
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 response = await fetch(
1086
- new URL(WORKSPACE_APPS_GATEWAY_PATH, `${base.replace(/\/$/, "")}/`),
1087
- {
1088
- headers: { accept: "application/json" },
1089
- signal: controller.signal,
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
- if (origin !== dispatchOrigin) return false;
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 candidates = agents
677
- .filter((agent) => normalizeAppId(agent.id) !== DISPATCH_APP_ID)
678
- .map((agent) => ({
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