@agent-native/dispatch 0.40.30 → 0.40.31
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/CHANGELOG.md +9 -13
- package/dist/actions/list-connected-agents.js +7 -3
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list-workspace-connections.d.ts.map +1 -1
- package/dist/actions/list-workspace-connections.js +13 -18
- package/dist/actions/list-workspace-connections.js.map +1 -1
- package/dist/components/action-query-error.d.ts +2 -1
- package/dist/components/action-query-error.d.ts.map +1 -1
- package/dist/components/action-query-error.js +25 -2
- package/dist/components/action-query-error.js.map +1 -1
- package/dist/components/chat-home-apps.d.ts.map +1 -1
- package/dist/components/chat-home-apps.js +2 -2
- package/dist/components/chat-home-apps.js.map +1 -1
- package/dist/components/dispatch-control-plane.d.ts.map +1 -1
- package/dist/components/dispatch-control-plane.js +1 -1
- package/dist/components/dispatch-control-plane.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +1 -0
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +7 -0
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/routes/pages/$appId.d.ts.map +1 -1
- package/dist/routes/pages/$appId.js +1 -1
- package/dist/routes/pages/$appId.js.map +1 -1
- package/dist/routes/pages/apps.js +1 -1
- package/dist/routes/pages/apps.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +122 -54
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/list-connected-agents.spec.ts +17 -1
- package/src/actions/list-connected-agents.ts +7 -2
- package/src/actions/list-workspace-connections.spec.ts +14 -3
- package/src/actions/list-workspace-connections.ts +13 -17
- package/src/components/action-query-error.spec.tsx +20 -0
- package/src/components/action-query-error.tsx +40 -0
- package/src/components/chat-home-apps.spec.tsx +32 -1
- package/src/components/chat-home-apps.tsx +6 -1
- package/src/components/dispatch-control-plane.tsx +1 -0
- package/src/components/layout/Layout.tsx +9 -0
- package/src/routes/pages/$appId.tsx +1 -0
- package/src/routes/pages/apps.spec.tsx +26 -2
- package/src/routes/pages/apps.tsx +2 -0
- package/src/server/lib/app-creation-store.spec.ts +281 -5
- package/src/server/lib/app-creation-store.ts +172 -60
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -29,6 +30,20 @@ import {
|
|
|
29
30
|
const originalFetch = globalThis.fetch;
|
|
30
31
|
const settingsKey = "dispatch-app-creation-settings:user:dev@example.test";
|
|
31
32
|
|
|
33
|
+
function verifyJwtWithSecret(
|
|
34
|
+
token: string,
|
|
35
|
+
secret: string,
|
|
36
|
+
): Record<string, unknown> {
|
|
37
|
+
const [header, payload, signature] = token.split(".");
|
|
38
|
+
const expected = createHmac("sha256", secret)
|
|
39
|
+
.update(`${header}.${payload}`)
|
|
40
|
+
.digest("base64url");
|
|
41
|
+
if (signature !== expected || !payload) {
|
|
42
|
+
throw new Error("JWT signature did not match the provided secret.");
|
|
43
|
+
}
|
|
44
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
const mocks = vi.hoisted(() => {
|
|
33
48
|
const settings = new Map<string, unknown>();
|
|
34
49
|
const state = {
|
|
@@ -55,6 +70,8 @@ const mocks = vi.hoisted(() => {
|
|
|
55
70
|
getOrgDomain: vi.fn(async (orgId: string) =>
|
|
56
71
|
orgId ? "example.test" : null,
|
|
57
72
|
),
|
|
73
|
+
getOrgA2ASecret: vi.fn(async () => null as string | null),
|
|
74
|
+
signA2AToken: vi.fn(),
|
|
58
75
|
isWorkspaceAppAccessAllowed: vi.fn(
|
|
59
76
|
async (): Promise<boolean | "unavailable"> => true,
|
|
60
77
|
),
|
|
@@ -131,6 +148,18 @@ const mocks = vi.hoisted(() => {
|
|
|
131
148
|
};
|
|
132
149
|
});
|
|
133
150
|
|
|
151
|
+
vi.mock("@agent-native/core/a2a", async (importOriginal) => {
|
|
152
|
+
const actual =
|
|
153
|
+
await importOriginal<typeof import("@agent-native/core/a2a")>();
|
|
154
|
+
mocks.signA2AToken.mockImplementation((...args: any[]) =>
|
|
155
|
+
actual.signA2AToken(...args),
|
|
156
|
+
);
|
|
157
|
+
return {
|
|
158
|
+
...actual,
|
|
159
|
+
signA2AToken: (...args: any[]) => mocks.signA2AToken(...args),
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
134
163
|
vi.mock("../../db/index.js", async (importOriginal) => {
|
|
135
164
|
const actual = await importOriginal<typeof import("../../db/index.js")>();
|
|
136
165
|
return { ...actual, getDb: mocks.getDb };
|
|
@@ -166,6 +195,7 @@ vi.mock("@agent-native/core/org", async (importOriginal) => {
|
|
|
166
195
|
await importOriginal<typeof import("@agent-native/core/org")>();
|
|
167
196
|
return {
|
|
168
197
|
...actual,
|
|
198
|
+
getOrgA2ASecret: (...args: any[]) => mocks.getOrgA2ASecret(...args),
|
|
169
199
|
getOrgDomain: (...args: any[]) => mocks.getOrgDomain(...args),
|
|
170
200
|
isWorkspaceAppAccessAllowed: (...args: any[]) =>
|
|
171
201
|
mocks.isWorkspaceAppAccessAllowed(...args),
|
|
@@ -218,6 +248,8 @@ afterEach(() => {
|
|
|
218
248
|
mocks.settings.clear();
|
|
219
249
|
mocks.getOrgSetting.mockReset();
|
|
220
250
|
mocks.getOrgSetting.mockResolvedValue(null);
|
|
251
|
+
mocks.getOrgA2ASecret.mockReset();
|
|
252
|
+
mocks.getOrgA2ASecret.mockResolvedValue(null);
|
|
221
253
|
mocks.isWorkspaceAppAccessAllowed.mockReset();
|
|
222
254
|
mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true);
|
|
223
255
|
mocks.mutateSetting.mockReset();
|
|
@@ -600,6 +632,130 @@ describe("listWorkspaceApps", () => {
|
|
|
600
632
|
expect(apps[0]?.url).toBe("https://agent-workspace.builder.io/atlas");
|
|
601
633
|
});
|
|
602
634
|
|
|
635
|
+
it("signs the hosted registry request with the caller's organization secret", async () => {
|
|
636
|
+
const fetchMock = vi.fn(
|
|
637
|
+
async () =>
|
|
638
|
+
new Response(JSON.stringify([]), {
|
|
639
|
+
headers: { "content-type": "application/json" },
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
643
|
+
vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
|
|
644
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
645
|
+
mocks.getOrgA2ASecret.mockResolvedValueOnce("workspace-org-secret");
|
|
646
|
+
|
|
647
|
+
await runWithRequestContext(
|
|
648
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
649
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const authorization = fetchMock.mock.calls[0]?.[1]?.headers
|
|
653
|
+
?.Authorization as string;
|
|
654
|
+
const token = authorization.slice("Bearer ".length);
|
|
655
|
+
const payload = verifyJwtWithSecret(token, "workspace-org-secret");
|
|
656
|
+
expect(payload).toMatchObject({
|
|
657
|
+
sub: "dev@example.test",
|
|
658
|
+
org_domain: "example.test",
|
|
659
|
+
aud: "https://agent-workspace.builder.io",
|
|
660
|
+
});
|
|
661
|
+
expect(() =>
|
|
662
|
+
verifyJwtWithSecret(token, "dispatch-deployment-secret"),
|
|
663
|
+
).toThrow();
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("reports missing hosted gateway signing credentials instead of sending anonymously", async () => {
|
|
667
|
+
const fetchMock = vi.fn();
|
|
668
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
669
|
+
vi.stubEnv("A2A_SECRET", "");
|
|
670
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
671
|
+
mocks.getOrgA2ASecret.mockResolvedValueOnce(null);
|
|
672
|
+
stubManifest([
|
|
673
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
674
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
675
|
+
]);
|
|
676
|
+
|
|
677
|
+
await expect(
|
|
678
|
+
runWithRequestContext(
|
|
679
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
680
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
681
|
+
),
|
|
682
|
+
).rejects.toMatchObject({
|
|
683
|
+
statusCode: 503,
|
|
684
|
+
errorCode: "workspace_gateway_a2a_secret_missing",
|
|
685
|
+
});
|
|
686
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("reports organization A2A secret lookup failures before using manifest apps", async () => {
|
|
690
|
+
const fetchMock = vi.fn();
|
|
691
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
692
|
+
vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
|
|
693
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
694
|
+
stubManifest([
|
|
695
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
696
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
697
|
+
]);
|
|
698
|
+
mocks.getOrgA2ASecret.mockRejectedValueOnce(
|
|
699
|
+
new Error("database unavailable"),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
await expect(
|
|
703
|
+
runWithRequestContext(
|
|
704
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
705
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
706
|
+
),
|
|
707
|
+
).rejects.toMatchObject({
|
|
708
|
+
statusCode: 503,
|
|
709
|
+
errorCode: "workspace_gateway_org_a2a_secret_unavailable",
|
|
710
|
+
});
|
|
711
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it("reports token signing failures before using manifest apps", async () => {
|
|
715
|
+
const fetchMock = vi.fn();
|
|
716
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
717
|
+
vi.stubEnv("A2A_SECRET", "dispatch-deployment-secret");
|
|
718
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
719
|
+
stubManifest([
|
|
720
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
721
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
722
|
+
]);
|
|
723
|
+
mocks.signA2AToken.mockRejectedValueOnce(new Error("signing unavailable"));
|
|
724
|
+
|
|
725
|
+
await expect(
|
|
726
|
+
runWithRequestContext(
|
|
727
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
728
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
729
|
+
),
|
|
730
|
+
).rejects.toMatchObject({
|
|
731
|
+
statusCode: 503,
|
|
732
|
+
errorCode: "workspace_gateway_token_signing_failed",
|
|
733
|
+
});
|
|
734
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("does not hide an organization lookup failure behind manifest apps", async () => {
|
|
738
|
+
const fetchMock = vi.fn();
|
|
739
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
740
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
741
|
+
stubManifest([
|
|
742
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
743
|
+
{ id: "clips", name: "Clips", path: "/clips" },
|
|
744
|
+
]);
|
|
745
|
+
mocks.getOrgDomain.mockRejectedValueOnce(new Error("database unavailable"));
|
|
746
|
+
|
|
747
|
+
await expect(
|
|
748
|
+
runWithRequestContext(
|
|
749
|
+
{ userEmail: "dev@example.test", orgId: "org-1" },
|
|
750
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
751
|
+
),
|
|
752
|
+
).rejects.toMatchObject({
|
|
753
|
+
statusCode: 503,
|
|
754
|
+
errorCode: "workspace_gateway_org_domain_unavailable",
|
|
755
|
+
});
|
|
756
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
757
|
+
});
|
|
758
|
+
|
|
603
759
|
it("binds hosted registry tokens to the mounted Dispatch path", async () => {
|
|
604
760
|
const fetchMock = vi.fn(async () => {
|
|
605
761
|
return new Response(
|
|
@@ -793,6 +949,122 @@ describe("listWorkspaceApps", () => {
|
|
|
793
949
|
expect(apps.map((app) => app.id)).toEqual(["dispatch", "clips"]);
|
|
794
950
|
});
|
|
795
951
|
|
|
952
|
+
it("treats an empty hosted registry response as a successful empty list", async () => {
|
|
953
|
+
const fetchMock = vi.fn(
|
|
954
|
+
async () =>
|
|
955
|
+
new Response(JSON.stringify([]), {
|
|
956
|
+
headers: { "content-type": "application/json" },
|
|
957
|
+
}),
|
|
958
|
+
);
|
|
959
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
960
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
961
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
962
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
963
|
+
|
|
964
|
+
const apps = await runWithRequestContext(
|
|
965
|
+
{ userEmail: "dev@example.test" },
|
|
966
|
+
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
967
|
+
);
|
|
968
|
+
|
|
969
|
+
expect(apps).toEqual([]);
|
|
970
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
it.each([206, 500, 502, 503])(
|
|
974
|
+
"surfaces hosted registry HTTP %i failures instead of using manifest apps",
|
|
975
|
+
async (status) => {
|
|
976
|
+
const fetchMock = vi.fn(
|
|
977
|
+
async () => new Response("gateway unavailable", { status }),
|
|
978
|
+
);
|
|
979
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
980
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
981
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
982
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
983
|
+
|
|
984
|
+
await expect(
|
|
985
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
986
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
987
|
+
),
|
|
988
|
+
).rejects.toMatchObject({
|
|
989
|
+
statusCode: 503,
|
|
990
|
+
errorCode: `workspace_gateway_http_${status}`,
|
|
991
|
+
});
|
|
992
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
993
|
+
},
|
|
994
|
+
);
|
|
995
|
+
|
|
996
|
+
it("surfaces hosted registry transport failures instead of using manifest apps", async () => {
|
|
997
|
+
const fetchMock = vi.fn(async () => {
|
|
998
|
+
throw new TypeError("Network connection failed");
|
|
999
|
+
});
|
|
1000
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1001
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
1002
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
1003
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
1004
|
+
|
|
1005
|
+
await expect(
|
|
1006
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
1007
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
1008
|
+
),
|
|
1009
|
+
).rejects.toMatchObject({
|
|
1010
|
+
statusCode: 503,
|
|
1011
|
+
errorCode: "workspace_gateway_unavailable",
|
|
1012
|
+
});
|
|
1013
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("surfaces malformed hosted registry responses instead of using manifest apps", async () => {
|
|
1017
|
+
const fetchMock = vi.fn(
|
|
1018
|
+
async () =>
|
|
1019
|
+
new Response("not json", {
|
|
1020
|
+
headers: { "content-type": "application/json" },
|
|
1021
|
+
}),
|
|
1022
|
+
);
|
|
1023
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1024
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
1025
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
1026
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
1027
|
+
|
|
1028
|
+
await expect(
|
|
1029
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
1030
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
1031
|
+
),
|
|
1032
|
+
).rejects.toMatchObject({
|
|
1033
|
+
statusCode: 503,
|
|
1034
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
1035
|
+
});
|
|
1036
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it.each([
|
|
1040
|
+
[{ id: "dispatch", name: "Dispatch", path: "/dispatch" }, null],
|
|
1041
|
+
[
|
|
1042
|
+
{ id: "dispatch", name: "Dispatch", path: "/dispatch" },
|
|
1043
|
+
{ id: "DISPATCH", name: "Duplicate Dispatch", path: "/other" },
|
|
1044
|
+
],
|
|
1045
|
+
])("rejects incomplete hosted registry lists: %j", async (apps) => {
|
|
1046
|
+
const fetchMock = vi.fn(
|
|
1047
|
+
async () =>
|
|
1048
|
+
new Response(JSON.stringify(apps), {
|
|
1049
|
+
headers: { "content-type": "application/json" },
|
|
1050
|
+
}),
|
|
1051
|
+
);
|
|
1052
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1053
|
+
vi.stubEnv("A2A_SECRET", "test-a2a-secret");
|
|
1054
|
+
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
1055
|
+
stubManifest([{ id: "dispatch", name: "Dispatch", path: "/dispatch" }]);
|
|
1056
|
+
|
|
1057
|
+
await expect(
|
|
1058
|
+
runWithRequestContext({ userEmail: "dev@example.test" }, () =>
|
|
1059
|
+
listWorkspaceApps({ includeAgentCards: false }),
|
|
1060
|
+
),
|
|
1061
|
+
).rejects.toMatchObject({
|
|
1062
|
+
statusCode: 503,
|
|
1063
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
1064
|
+
});
|
|
1065
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
796
1068
|
it("passes the Vercel protection bypass to the hosted workspace registry", async () => {
|
|
797
1069
|
const fetchMock = vi.fn(async () => {
|
|
798
1070
|
return new Response(
|
|
@@ -859,10 +1131,10 @@ describe("listWorkspaceApps", () => {
|
|
|
859
1131
|
);
|
|
860
1132
|
});
|
|
861
1133
|
|
|
862
|
-
it("
|
|
1134
|
+
it("skips a same-origin hosted registry without credentials", async () => {
|
|
863
1135
|
const fetchMock = vi.fn();
|
|
864
1136
|
vi.stubGlobal("fetch", fetchMock);
|
|
865
|
-
vi.stubEnv("A2A_SECRET", "
|
|
1137
|
+
vi.stubEnv("A2A_SECRET", "");
|
|
866
1138
|
vi.stubEnv("WORKSPACE_GATEWAY_URL", "https://agent-workspace.builder.io");
|
|
867
1139
|
|
|
868
1140
|
const apps = await runWithRequestContext(
|
|
@@ -891,9 +1163,13 @@ describe("listWorkspaceApps", () => {
|
|
|
891
1163
|
{ userEmail: "dev@example.test", orgId: "org-without-domain" },
|
|
892
1164
|
() => listWorkspaceApps({ includeAgentCards: false }),
|
|
893
1165
|
),
|
|
894
|
-
).rejects.
|
|
895
|
-
|
|
896
|
-
|
|
1166
|
+
).rejects.toMatchObject({
|
|
1167
|
+
name: "WorkspaceAppsGatewayAuthorizationError",
|
|
1168
|
+
statusCode: 401,
|
|
1169
|
+
errorCode: "workspace_gateway_org_domain_missing",
|
|
1170
|
+
message:
|
|
1171
|
+
"Workspace apps gateway cannot authenticate without a resolved organization domain.",
|
|
1172
|
+
});
|
|
897
1173
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
898
1174
|
});
|
|
899
1175
|
|
|
@@ -3,12 +3,20 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
canonicalA2AAudience,
|
|
8
|
+
getGlobalA2ASecret,
|
|
9
|
+
signA2AToken,
|
|
10
|
+
} from "@agent-native/core/a2a";
|
|
11
|
+
import {
|
|
12
|
+
ActionContractError,
|
|
13
|
+
isActionContractError,
|
|
14
|
+
} from "@agent-native/core/action";
|
|
8
15
|
import { describeErrorWithCauses } from "@agent-native/core/agent/engine/error-detail";
|
|
9
16
|
import { getDbExec } from "@agent-native/core/db";
|
|
10
17
|
import { and, eq, inArray } from "@agent-native/core/db/schema";
|
|
11
18
|
import {
|
|
19
|
+
getOrgA2ASecret,
|
|
12
20
|
getOrgDomain,
|
|
13
21
|
isWorkspaceAppAccessAllowed,
|
|
14
22
|
workspaceApps,
|
|
@@ -152,17 +160,15 @@ function requireWorkspaceAppOrgId(): string {
|
|
|
152
160
|
return orgId;
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
class WorkspaceAppsGatewayAuthorizationError extends
|
|
163
|
+
class WorkspaceAppsGatewayAuthorizationError extends ActionContractError {
|
|
156
164
|
constructor(
|
|
157
|
-
statusCode: 401 | 403,
|
|
165
|
+
statusCode: 401 | 403 | 503,
|
|
166
|
+
errorCode = "workspace_gateway_rejected",
|
|
158
167
|
message = `Workspace apps gateway rejected the request with HTTP ${statusCode}.`,
|
|
159
168
|
) {
|
|
160
|
-
super(message);
|
|
169
|
+
super(message, { statusCode, errorCode });
|
|
161
170
|
this.name = "WorkspaceAppsGatewayAuthorizationError";
|
|
162
|
-
this.statusCode = statusCode;
|
|
163
171
|
}
|
|
164
|
-
|
|
165
|
-
statusCode: 401 | 403;
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
function warnWorkspaceAppsGatewayDenial(
|
|
@@ -871,6 +877,21 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
|
|
|
871
877
|
return apps.length ? apps : null;
|
|
872
878
|
}
|
|
873
879
|
|
|
880
|
+
function parseWorkspaceAppsGatewayResponse(
|
|
881
|
+
parsed: unknown,
|
|
882
|
+
): WorkspaceAppSummary[] | null {
|
|
883
|
+
const rawApps =
|
|
884
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
885
|
+
? (parsed as { apps?: unknown }).apps
|
|
886
|
+
: parsed;
|
|
887
|
+
if (!Array.isArray(rawApps)) return null;
|
|
888
|
+
if (rawApps.length === 0) return [];
|
|
889
|
+
const apps = parseWorkspaceAppsManifest(parsed);
|
|
890
|
+
if (!apps || apps.length !== rawApps.length) return null;
|
|
891
|
+
const ids = new Set(apps.map((app) => app.id.toLowerCase()));
|
|
892
|
+
return ids.size === apps.length ? apps : null;
|
|
893
|
+
}
|
|
894
|
+
|
|
874
895
|
function parseDateMs(value: string | null | undefined): number | null {
|
|
875
896
|
if (!value) return null;
|
|
876
897
|
const ms = Date.parse(value);
|
|
@@ -1863,37 +1884,89 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1863
1884
|
}
|
|
1864
1885
|
|
|
1865
1886
|
const requestContext = getRequestContext();
|
|
1887
|
+
const isLocalGateway = isLocalWorkspaceGateway(baseUrl);
|
|
1888
|
+
const requestOrigin = requestContext?.requestOrigin;
|
|
1889
|
+
const isSameOriginGateway = requestOrigin
|
|
1890
|
+
? (() => {
|
|
1891
|
+
try {
|
|
1892
|
+
return new URL(requestOrigin).origin === baseUrl.origin;
|
|
1893
|
+
} catch {
|
|
1894
|
+
// coercion-ok: an invalid request origin cannot prove a self-fetch.
|
|
1895
|
+
return false;
|
|
1896
|
+
}
|
|
1897
|
+
})()
|
|
1898
|
+
: false;
|
|
1899
|
+
if (isSameOriginGateway && !isLocalGateway) return null;
|
|
1900
|
+
|
|
1866
1901
|
const authHeaders: Record<string, string> = {};
|
|
1867
1902
|
if (requestContext?.userEmail) {
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1903
|
+
let orgDomain: string | undefined;
|
|
1904
|
+
let orgSecret: string | undefined;
|
|
1905
|
+
if (requestContext.orgId) {
|
|
1906
|
+
try {
|
|
1907
|
+
orgDomain =
|
|
1908
|
+
(await getOrgDomain(requestContext.orgId))?.trim().toLowerCase() ||
|
|
1909
|
+
undefined;
|
|
1910
|
+
} catch {
|
|
1911
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1912
|
+
503,
|
|
1913
|
+
"workspace_gateway_org_domain_unavailable",
|
|
1914
|
+
"Workspace apps gateway organization domain could not be read.",
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1871
1918
|
if (requestContext.orgId && !orgDomain) {
|
|
1872
1919
|
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1873
1920
|
401,
|
|
1921
|
+
"workspace_gateway_org_domain_missing",
|
|
1874
1922
|
"Workspace apps gateway cannot authenticate without a resolved organization domain.",
|
|
1875
1923
|
);
|
|
1876
1924
|
}
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1925
|
+
if (requestContext.orgId) {
|
|
1926
|
+
try {
|
|
1927
|
+
orgSecret =
|
|
1928
|
+
(await getOrgA2ASecret(requestContext.orgId))?.trim() || undefined;
|
|
1929
|
+
} catch {
|
|
1930
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1931
|
+
503,
|
|
1932
|
+
"workspace_gateway_org_a2a_secret_unavailable",
|
|
1933
|
+
"Workspace apps gateway organization A2A secret could not be read.",
|
|
1934
|
+
);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const globalSecret = getGlobalA2ASecret();
|
|
1938
|
+
const allowLocalUnauthenticatedRequest =
|
|
1939
|
+
isLocalGateway && !orgSecret && !globalSecret;
|
|
1940
|
+
if (!orgSecret && !globalSecret && !allowLocalUnauthenticatedRequest) {
|
|
1941
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1942
|
+
503,
|
|
1943
|
+
"workspace_gateway_a2a_secret_missing",
|
|
1944
|
+
"Workspace apps gateway cannot authenticate because no A2A secret is configured.",
|
|
1890
1945
|
);
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1946
|
+
}
|
|
1947
|
+
if (!allowLocalUnauthenticatedRequest) {
|
|
1948
|
+
try {
|
|
1949
|
+
const token = await signA2AToken(
|
|
1950
|
+
requestContext.userEmail,
|
|
1951
|
+
orgDomain,
|
|
1952
|
+
orgSecret,
|
|
1953
|
+
{
|
|
1954
|
+
expiresIn: "1m",
|
|
1955
|
+
preferGlobalSecret: false,
|
|
1956
|
+
audience: canonicalA2AAudience(
|
|
1957
|
+
baseUrl.toString(),
|
|
1958
|
+
getConfiguredAppBasePath() || undefined,
|
|
1959
|
+
),
|
|
1960
|
+
},
|
|
1961
|
+
);
|
|
1962
|
+
authHeaders.Authorization = `Bearer ${token}`;
|
|
1963
|
+
} catch {
|
|
1964
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1965
|
+
503,
|
|
1966
|
+
"workspace_gateway_token_signing_failed",
|
|
1967
|
+
"Workspace apps gateway could not sign the authenticated request.",
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1897
1970
|
}
|
|
1898
1971
|
}
|
|
1899
1972
|
|
|
@@ -1923,9 +1996,34 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1923
1996
|
const protectedRedirect = protectionHeaders["x-vercel-protection-bypass"]
|
|
1924
1997
|
? { redirect: "manual" as const }
|
|
1925
1998
|
: {};
|
|
1999
|
+
const readAppsResponse = async (response: Response) => {
|
|
2000
|
+
let payload: unknown;
|
|
2001
|
+
try {
|
|
2002
|
+
payload = await response.json();
|
|
2003
|
+
} catch {
|
|
2004
|
+
throw new ActionContractError(
|
|
2005
|
+
"Workspace apps gateway returned an unreadable response.",
|
|
2006
|
+
{
|
|
2007
|
+
statusCode: 503,
|
|
2008
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
2009
|
+
},
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
2012
|
+
const apps = parseWorkspaceAppsGatewayResponse(payload);
|
|
2013
|
+
if (!apps) {
|
|
2014
|
+
throw new ActionContractError(
|
|
2015
|
+
"Workspace apps gateway returned an invalid response.",
|
|
2016
|
+
{
|
|
2017
|
+
statusCode: 503,
|
|
2018
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
2019
|
+
},
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
return { apps };
|
|
2023
|
+
};
|
|
1926
2024
|
|
|
1927
2025
|
try {
|
|
1928
|
-
if (
|
|
2026
|
+
if (isLocalGateway) {
|
|
1929
2027
|
const localResponse = await fetch(
|
|
1930
2028
|
gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH),
|
|
1931
2029
|
{
|
|
@@ -1935,29 +2033,25 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1935
2033
|
},
|
|
1936
2034
|
);
|
|
1937
2035
|
if (localResponse.status === 401 || localResponse.status === 403) {
|
|
1938
|
-
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2036
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2037
|
+
localResponse.status,
|
|
2038
|
+
"workspace_gateway_rejected",
|
|
2039
|
+
);
|
|
1939
2040
|
}
|
|
1940
|
-
if (localResponse.
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2041
|
+
if (localResponse.status !== 404 && localResponse.status !== 200) {
|
|
2042
|
+
throw new ActionContractError(
|
|
2043
|
+
`Workspace apps gateway returned HTTP ${localResponse.status}.`,
|
|
2044
|
+
{
|
|
2045
|
+
statusCode: 503,
|
|
2046
|
+
errorCode: `workspace_gateway_http_${localResponse.status}`,
|
|
2047
|
+
},
|
|
1945
2048
|
);
|
|
1946
|
-
|
|
2049
|
+
}
|
|
2050
|
+
if (localResponse.status === 200) {
|
|
2051
|
+
return await readAppsResponse(localResponse);
|
|
1947
2052
|
}
|
|
1948
2053
|
}
|
|
1949
2054
|
|
|
1950
|
-
const requestOrigin = requestContext?.requestOrigin;
|
|
1951
|
-
const isSameOriginGateway = requestOrigin
|
|
1952
|
-
? (() => {
|
|
1953
|
-
try {
|
|
1954
|
-
return new URL(requestOrigin).origin === baseUrl.origin;
|
|
1955
|
-
} catch {
|
|
1956
|
-
// coercion-ok: an invalid request origin cannot prove a self-fetch.
|
|
1957
|
-
return false;
|
|
1958
|
-
}
|
|
1959
|
-
})()
|
|
1960
|
-
: false;
|
|
1961
2055
|
if (isSameOriginGateway || !authHeaders.Authorization) return null;
|
|
1962
2056
|
const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
|
|
1963
2057
|
actionUrl.searchParams.set("includeAgentCards", "false");
|
|
@@ -1968,18 +2062,31 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1968
2062
|
signal: controller.signal,
|
|
1969
2063
|
});
|
|
1970
2064
|
if (actionResponse.status === 401 || actionResponse.status === 403) {
|
|
1971
|
-
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2065
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2066
|
+
actionResponse.status,
|
|
2067
|
+
"workspace_gateway_rejected",
|
|
2068
|
+
);
|
|
1972
2069
|
}
|
|
1973
|
-
if (
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
2070
|
+
if (actionResponse.status === 404) return null;
|
|
2071
|
+
if (actionResponse.status !== 200) {
|
|
2072
|
+
throw new ActionContractError(
|
|
2073
|
+
`Workspace apps gateway returned HTTP ${actionResponse.status}.`,
|
|
2074
|
+
{
|
|
2075
|
+
statusCode: 503,
|
|
2076
|
+
errorCode: `workspace_gateway_http_${actionResponse.status}`,
|
|
2077
|
+
},
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
return await readAppsResponse(actionResponse);
|
|
1980
2081
|
} catch (error) {
|
|
1981
|
-
if (error instanceof
|
|
1982
|
-
|
|
2082
|
+
if (error instanceof ActionContractError) throw error;
|
|
2083
|
+
throw new ActionContractError(
|
|
2084
|
+
"Workspace apps gateway could not be reached.",
|
|
2085
|
+
{
|
|
2086
|
+
statusCode: 503,
|
|
2087
|
+
errorCode: "workspace_gateway_unavailable",
|
|
2088
|
+
},
|
|
2089
|
+
);
|
|
1983
2090
|
} finally {
|
|
1984
2091
|
clearTimeout(timeout);
|
|
1985
2092
|
}
|
|
@@ -2260,6 +2367,9 @@ async function listWorkspaceAppsWithPrincipal(
|
|
|
2260
2367
|
if (gatewayApps) {
|
|
2261
2368
|
return finalize(gatewayApps.apps);
|
|
2262
2369
|
}
|
|
2370
|
+
if (gatewayDenial && gatewayDenial.statusCode >= 500) {
|
|
2371
|
+
throw gatewayDenial;
|
|
2372
|
+
}
|
|
2263
2373
|
const unverified = gatewayDenial !== null;
|
|
2264
2374
|
|
|
2265
2375
|
const workspaceRoot = findWorkspaceRoot();
|
|
@@ -2284,7 +2394,9 @@ async function listWorkspaceAppsWithPrincipal(
|
|
|
2284
2394
|
// means the registry will not show this reader its apps, so they get the
|
|
2285
2395
|
// deployment's own list instead, and none of it is recorded. (A manifest
|
|
2286
2396
|
// answers earlier for both, with the warning above.)
|
|
2287
|
-
if (gatewayDenial
|
|
2397
|
+
if (gatewayDenial && gatewayDenial.statusCode !== 403) {
|
|
2398
|
+
throw gatewayDenial;
|
|
2399
|
+
}
|
|
2288
2400
|
warnWorkspaceAppsGatewayDenial(gatewayDenial, "deployment's own app list");
|
|
2289
2401
|
|
|
2290
2402
|
if (!workspaceRoot) {
|