@agent-native/dispatch 0.40.30 → 0.40.33
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 +28 -25
- package/dist/actions/connect-external-agent.d.ts.map +1 -1
- package/dist/actions/connect-external-agent.js +2 -9
- package/dist/actions/connect-external-agent.js.map +1 -1
- package/dist/actions/list-connected-agents.js +7 -3
- package/dist/actions/list-connected-agents.js.map +1 -1
- package/dist/actions/list-dispatch-usage-metrics.js +1 -1
- package/dist/actions/list-dispatch-usage-metrics.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/actions/provider-api-register.d.ts +8 -8
- package/dist/actions/provider-api-register.js +2 -14
- package/dist/actions/provider-api-register.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/db/index.d.ts.map +1 -1
- package/dist/db/index.js +4 -9
- package/dist/db/index.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/admin-config.d.ts.map +1 -1
- package/dist/server/lib/admin-config.js +3 -1
- package/dist/server/lib/admin-config.js.map +1 -1
- package/dist/server/lib/app-creation-store.d.ts.map +1 -1
- package/dist/server/lib/app-creation-store.js +124 -65
- package/dist/server/lib/app-creation-store.js.map +1 -1
- package/dist/server/lib/community-apps.js +1 -1
- package/dist/server/lib/community-apps.js.map +1 -1
- package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
- package/dist/server/lib/mcp-access-store.js +2 -10
- package/dist/server/lib/mcp-access-store.js.map +1 -1
- package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
- package/dist/server/lib/thread-debug-store.js +2 -6
- package/dist/server/lib/thread-debug-store.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -1
- package/dist/server/lib/usage-metrics-store.js +2 -11
- package/dist/server/lib/usage-metrics-store.js.map +1 -1
- package/dist/server/lib/vault-store.d.ts.map +1 -1
- package/dist/server/lib/vault-store.js +2 -9
- package/dist/server/lib/vault-store.js.map +1 -1
- package/dist/server/lib/workspace-app-action-auth.d.ts.map +1 -1
- package/dist/server/lib/workspace-app-action-auth.js +7 -2
- package/dist/server/lib/workspace-app-action-auth.js.map +1 -1
- package/dist/server/plugins/agent-chat.js +1 -1
- package/dist/server/plugins/agent-chat.js.map +1 -1
- package/package.json +3 -3
- package/src/actions/connect-external-agent.spec.ts +25 -11
- package/src/actions/connect-external-agent.ts +2 -9
- package/src/actions/index.spec.ts +3 -0
- package/src/actions/list-connected-agents.spec.ts +17 -1
- package/src/actions/list-connected-agents.ts +7 -2
- package/src/actions/list-dispatch-usage-metrics.ts +1 -1
- package/src/actions/list-workspace-connections.spec.ts +14 -3
- package/src/actions/list-workspace-connections.ts +13 -17
- package/src/actions/provider-api-register.spec.ts +15 -9
- package/src/actions/provider-api-register.ts +5 -13
- 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/db/index.spec.ts +74 -0
- package/src/db/index.ts +4 -9
- 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/admin-config.spec.ts +51 -0
- package/src/server/lib/admin-config.ts +2 -1
- package/src/server/lib/app-creation-store.spec.ts +308 -10
- package/src/server/lib/app-creation-store.ts +174 -70
- package/src/server/lib/community-apps.ts +1 -1
- package/src/server/lib/mcp-access-store.spec.ts +39 -12
- package/src/server/lib/mcp-access-store.ts +3 -10
- package/src/server/lib/thread-debug-store.spec.ts +64 -0
- package/src/server/lib/thread-debug-store.ts +2 -9
- package/src/server/lib/usage-metrics-store.spec.ts +64 -0
- package/src/server/lib/usage-metrics-store.ts +2 -17
- package/src/server/lib/vault-store.spec.ts +26 -9
- package/src/server/lib/vault-store.ts +3 -10
- package/src/server/lib/workspace-app-action-auth.spec.ts +37 -6
- package/src/server/lib/workspace-app-action-auth.ts +12 -2
- package/src/server/plugins/agent-chat.spec.ts +14 -0
- package/src/server/plugins/agent-chat.ts +1 -1
|
@@ -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,
|
|
@@ -23,6 +31,7 @@ import {
|
|
|
23
31
|
inferWorkspaceAppRootHomePath,
|
|
24
32
|
isIntegrationCallerRequest,
|
|
25
33
|
readConfiguredWorkspaceAppHomePath,
|
|
34
|
+
readOrgMemberRole,
|
|
26
35
|
resolveAppRuntimeUrl,
|
|
27
36
|
resolveVercelDeploymentProtectionHeaders,
|
|
28
37
|
normalizeBuilderAgentAttachments,
|
|
@@ -152,17 +161,15 @@ function requireWorkspaceAppOrgId(): string {
|
|
|
152
161
|
return orgId;
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
class WorkspaceAppsGatewayAuthorizationError extends
|
|
164
|
+
class WorkspaceAppsGatewayAuthorizationError extends ActionContractError {
|
|
156
165
|
constructor(
|
|
157
|
-
statusCode: 401 | 403,
|
|
166
|
+
statusCode: 401 | 403 | 503,
|
|
167
|
+
errorCode = "workspace_gateway_rejected",
|
|
158
168
|
message = `Workspace apps gateway rejected the request with HTTP ${statusCode}.`,
|
|
159
169
|
) {
|
|
160
|
-
super(message);
|
|
170
|
+
super(message, { statusCode, errorCode });
|
|
161
171
|
this.name = "WorkspaceAppsGatewayAuthorizationError";
|
|
162
|
-
this.statusCode = statusCode;
|
|
163
172
|
}
|
|
164
|
-
|
|
165
|
-
statusCode: 401 | 403;
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
function warnWorkspaceAppsGatewayDenial(
|
|
@@ -871,6 +878,21 @@ function parseWorkspaceAppsManifest(parsed: any): WorkspaceAppSummary[] | null {
|
|
|
871
878
|
return apps.length ? apps : null;
|
|
872
879
|
}
|
|
873
880
|
|
|
881
|
+
function parseWorkspaceAppsGatewayResponse(
|
|
882
|
+
parsed: unknown,
|
|
883
|
+
): WorkspaceAppSummary[] | null {
|
|
884
|
+
const rawApps =
|
|
885
|
+
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
886
|
+
? (parsed as { apps?: unknown }).apps
|
|
887
|
+
: parsed;
|
|
888
|
+
if (!Array.isArray(rawApps)) return null;
|
|
889
|
+
if (rawApps.length === 0) return [];
|
|
890
|
+
const apps = parseWorkspaceAppsManifest(parsed);
|
|
891
|
+
if (!apps || apps.length !== rawApps.length) return null;
|
|
892
|
+
const ids = new Set(apps.map((app) => app.id.toLowerCase()));
|
|
893
|
+
return ids.size === apps.length ? apps : null;
|
|
894
|
+
}
|
|
895
|
+
|
|
874
896
|
function parseDateMs(value: string | null | undefined): number | null {
|
|
875
897
|
if (!value) return null;
|
|
876
898
|
const ms = Date.parse(value);
|
|
@@ -1863,37 +1885,89 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1863
1885
|
}
|
|
1864
1886
|
|
|
1865
1887
|
const requestContext = getRequestContext();
|
|
1888
|
+
const isLocalGateway = isLocalWorkspaceGateway(baseUrl);
|
|
1889
|
+
const requestOrigin = requestContext?.requestOrigin;
|
|
1890
|
+
const isSameOriginGateway = requestOrigin
|
|
1891
|
+
? (() => {
|
|
1892
|
+
try {
|
|
1893
|
+
return new URL(requestOrigin).origin === baseUrl.origin;
|
|
1894
|
+
} catch {
|
|
1895
|
+
// coercion-ok: an invalid request origin cannot prove a self-fetch.
|
|
1896
|
+
return false;
|
|
1897
|
+
}
|
|
1898
|
+
})()
|
|
1899
|
+
: false;
|
|
1900
|
+
if (isSameOriginGateway && !isLocalGateway) return null;
|
|
1901
|
+
|
|
1866
1902
|
const authHeaders: Record<string, string> = {};
|
|
1867
1903
|
if (requestContext?.userEmail) {
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1904
|
+
let orgDomain: string | undefined;
|
|
1905
|
+
let orgSecret: string | undefined;
|
|
1906
|
+
if (requestContext.orgId) {
|
|
1907
|
+
try {
|
|
1908
|
+
orgDomain =
|
|
1909
|
+
(await getOrgDomain(requestContext.orgId))?.trim().toLowerCase() ||
|
|
1910
|
+
undefined;
|
|
1911
|
+
} catch {
|
|
1912
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1913
|
+
503,
|
|
1914
|
+
"workspace_gateway_org_domain_unavailable",
|
|
1915
|
+
"Workspace apps gateway organization domain could not be read.",
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1871
1919
|
if (requestContext.orgId && !orgDomain) {
|
|
1872
1920
|
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1873
1921
|
401,
|
|
1922
|
+
"workspace_gateway_org_domain_missing",
|
|
1874
1923
|
"Workspace apps gateway cannot authenticate without a resolved organization domain.",
|
|
1875
1924
|
);
|
|
1876
1925
|
}
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1926
|
+
if (requestContext.orgId) {
|
|
1927
|
+
try {
|
|
1928
|
+
orgSecret =
|
|
1929
|
+
(await getOrgA2ASecret(requestContext.orgId))?.trim() || undefined;
|
|
1930
|
+
} catch {
|
|
1931
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1932
|
+
503,
|
|
1933
|
+
"workspace_gateway_org_a2a_secret_unavailable",
|
|
1934
|
+
"Workspace apps gateway organization A2A secret could not be read.",
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
const globalSecret = getGlobalA2ASecret();
|
|
1939
|
+
const allowLocalUnauthenticatedRequest =
|
|
1940
|
+
isLocalGateway && !orgSecret && !globalSecret;
|
|
1941
|
+
if (!orgSecret && !globalSecret && !allowLocalUnauthenticatedRequest) {
|
|
1942
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1943
|
+
503,
|
|
1944
|
+
"workspace_gateway_a2a_secret_missing",
|
|
1945
|
+
"Workspace apps gateway cannot authenticate because no A2A secret is configured.",
|
|
1890
1946
|
);
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1947
|
+
}
|
|
1948
|
+
if (!allowLocalUnauthenticatedRequest) {
|
|
1949
|
+
try {
|
|
1950
|
+
const token = await signA2AToken(
|
|
1951
|
+
requestContext.userEmail,
|
|
1952
|
+
orgDomain,
|
|
1953
|
+
orgSecret,
|
|
1954
|
+
{
|
|
1955
|
+
expiresIn: "1m",
|
|
1956
|
+
preferGlobalSecret: false,
|
|
1957
|
+
audience: canonicalA2AAudience(
|
|
1958
|
+
baseUrl.toString(),
|
|
1959
|
+
getConfiguredAppBasePath() || undefined,
|
|
1960
|
+
),
|
|
1961
|
+
},
|
|
1962
|
+
);
|
|
1963
|
+
authHeaders.Authorization = `Bearer ${token}`;
|
|
1964
|
+
} catch {
|
|
1965
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
1966
|
+
503,
|
|
1967
|
+
"workspace_gateway_token_signing_failed",
|
|
1968
|
+
"Workspace apps gateway could not sign the authenticated request.",
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1897
1971
|
}
|
|
1898
1972
|
}
|
|
1899
1973
|
|
|
@@ -1923,9 +1997,34 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1923
1997
|
const protectedRedirect = protectionHeaders["x-vercel-protection-bypass"]
|
|
1924
1998
|
? { redirect: "manual" as const }
|
|
1925
1999
|
: {};
|
|
2000
|
+
const readAppsResponse = async (response: Response) => {
|
|
2001
|
+
let payload: unknown;
|
|
2002
|
+
try {
|
|
2003
|
+
payload = await response.json();
|
|
2004
|
+
} catch {
|
|
2005
|
+
throw new ActionContractError(
|
|
2006
|
+
"Workspace apps gateway returned an unreadable response.",
|
|
2007
|
+
{
|
|
2008
|
+
statusCode: 503,
|
|
2009
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
2010
|
+
},
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
const apps = parseWorkspaceAppsGatewayResponse(payload);
|
|
2014
|
+
if (!apps) {
|
|
2015
|
+
throw new ActionContractError(
|
|
2016
|
+
"Workspace apps gateway returned an invalid response.",
|
|
2017
|
+
{
|
|
2018
|
+
statusCode: 503,
|
|
2019
|
+
errorCode: "workspace_gateway_invalid_response",
|
|
2020
|
+
},
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
return { apps };
|
|
2024
|
+
};
|
|
1926
2025
|
|
|
1927
2026
|
try {
|
|
1928
|
-
if (
|
|
2027
|
+
if (isLocalGateway) {
|
|
1929
2028
|
const localResponse = await fetch(
|
|
1930
2029
|
gatewayUrl(WORKSPACE_APPS_GATEWAY_PATH),
|
|
1931
2030
|
{
|
|
@@ -1935,29 +2034,25 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1935
2034
|
},
|
|
1936
2035
|
);
|
|
1937
2036
|
if (localResponse.status === 401 || localResponse.status === 403) {
|
|
1938
|
-
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2037
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2038
|
+
localResponse.status,
|
|
2039
|
+
"workspace_gateway_rejected",
|
|
2040
|
+
);
|
|
1939
2041
|
}
|
|
1940
|
-
if (localResponse.
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
2042
|
+
if (localResponse.status !== 404 && localResponse.status !== 200) {
|
|
2043
|
+
throw new ActionContractError(
|
|
2044
|
+
`Workspace apps gateway returned HTTP ${localResponse.status}.`,
|
|
2045
|
+
{
|
|
2046
|
+
statusCode: 503,
|
|
2047
|
+
errorCode: `workspace_gateway_http_${localResponse.status}`,
|
|
2048
|
+
},
|
|
1945
2049
|
);
|
|
1946
|
-
|
|
2050
|
+
}
|
|
2051
|
+
if (localResponse.status === 200) {
|
|
2052
|
+
return await readAppsResponse(localResponse);
|
|
1947
2053
|
}
|
|
1948
2054
|
}
|
|
1949
2055
|
|
|
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
2056
|
if (isSameOriginGateway || !authHeaders.Authorization) return null;
|
|
1962
2057
|
const actionUrl = gatewayUrl(WORKSPACE_APPS_ACTION_PATH);
|
|
1963
2058
|
actionUrl.searchParams.set("includeAgentCards", "false");
|
|
@@ -1968,18 +2063,31 @@ async function readWorkspaceAppsFromGateway(): Promise<WorkspaceAppDiscovery | n
|
|
|
1968
2063
|
signal: controller.signal,
|
|
1969
2064
|
});
|
|
1970
2065
|
if (actionResponse.status === 401 || actionResponse.status === 403) {
|
|
1971
|
-
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2066
|
+
throw new WorkspaceAppsGatewayAuthorizationError(
|
|
2067
|
+
actionResponse.status,
|
|
2068
|
+
"workspace_gateway_rejected",
|
|
2069
|
+
);
|
|
1972
2070
|
}
|
|
1973
|
-
if (
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
2071
|
+
if (actionResponse.status === 404) return null;
|
|
2072
|
+
if (actionResponse.status !== 200) {
|
|
2073
|
+
throw new ActionContractError(
|
|
2074
|
+
`Workspace apps gateway returned HTTP ${actionResponse.status}.`,
|
|
2075
|
+
{
|
|
2076
|
+
statusCode: 503,
|
|
2077
|
+
errorCode: `workspace_gateway_http_${actionResponse.status}`,
|
|
2078
|
+
},
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
return await readAppsResponse(actionResponse);
|
|
1980
2082
|
} catch (error) {
|
|
1981
|
-
if (error instanceof
|
|
1982
|
-
|
|
2083
|
+
if (error instanceof ActionContractError) throw error;
|
|
2084
|
+
throw new ActionContractError(
|
|
2085
|
+
"Workspace apps gateway could not be reached.",
|
|
2086
|
+
{
|
|
2087
|
+
statusCode: 503,
|
|
2088
|
+
errorCode: "workspace_gateway_unavailable",
|
|
2089
|
+
},
|
|
2090
|
+
);
|
|
1983
2091
|
} finally {
|
|
1984
2092
|
clearTimeout(timeout);
|
|
1985
2093
|
}
|
|
@@ -2260,6 +2368,9 @@ async function listWorkspaceAppsWithPrincipal(
|
|
|
2260
2368
|
if (gatewayApps) {
|
|
2261
2369
|
return finalize(gatewayApps.apps);
|
|
2262
2370
|
}
|
|
2371
|
+
if (gatewayDenial && gatewayDenial.statusCode >= 500) {
|
|
2372
|
+
throw gatewayDenial;
|
|
2373
|
+
}
|
|
2263
2374
|
const unverified = gatewayDenial !== null;
|
|
2264
2375
|
|
|
2265
2376
|
const workspaceRoot = findWorkspaceRoot();
|
|
@@ -2284,7 +2395,9 @@ async function listWorkspaceAppsWithPrincipal(
|
|
|
2284
2395
|
// means the registry will not show this reader its apps, so they get the
|
|
2285
2396
|
// deployment's own list instead, and none of it is recorded. (A manifest
|
|
2286
2397
|
// answers earlier for both, with the warning above.)
|
|
2287
|
-
if (gatewayDenial
|
|
2398
|
+
if (gatewayDenial && gatewayDenial.statusCode !== 403) {
|
|
2399
|
+
throw gatewayDenial;
|
|
2400
|
+
}
|
|
2288
2401
|
warnWorkspaceAppsGatewayDenial(gatewayDenial, "deployment's own app list");
|
|
2289
2402
|
|
|
2290
2403
|
if (!workspaceRoot) {
|
|
@@ -2715,18 +2828,9 @@ function isSyntheticIntegrationOwner(ownerEmail: string): boolean {
|
|
|
2715
2828
|
|
|
2716
2829
|
async function requestOwnerRole(): Promise<string | null> {
|
|
2717
2830
|
const orgId = currentOrgId();
|
|
2718
|
-
const ownerEmail = currentOwnerEmail();
|
|
2719
2831
|
if (!orgId) return null;
|
|
2720
2832
|
try {
|
|
2721
|
-
|
|
2722
|
-
sql: `SELECT role FROM org_members
|
|
2723
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
2724
|
-
AND federation_removal_pending_at IS NULL
|
|
2725
|
-
LIMIT 1`,
|
|
2726
|
-
args: [orgId, ownerEmail.toLowerCase()],
|
|
2727
|
-
});
|
|
2728
|
-
const role = (rows[0] as any)?.role;
|
|
2729
|
-
return typeof role === "string" ? role : null;
|
|
2833
|
+
return await readOrgMemberRole(orgId, currentOwnerEmail());
|
|
2730
2834
|
} catch (error) {
|
|
2731
2835
|
throw new AppCreationOwnerRoleUnavailableError(error);
|
|
2732
2836
|
}
|
|
@@ -95,7 +95,7 @@ export const CURATED_COMMUNITY_APP_SOURCES: readonly CuratedCommunityAppSource[]
|
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
path: "package.json",
|
|
98
|
-
blobSha: "
|
|
98
|
+
blobSha: "de917f7c794eceef303bb6f5a434ece39a6ab6fd",
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
path: "server/db/index.ts",
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
|
-
|
|
4
|
+
readOrgMemberRole: vi.fn(),
|
|
5
5
|
getOrgSetting: vi.fn(),
|
|
6
6
|
getUserSetting: vi.fn(),
|
|
7
7
|
putOrgSetting: vi.fn(),
|
|
8
8
|
putUserSetting: vi.fn(),
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
|
-
vi.mock("@agent-native/core/
|
|
12
|
-
|
|
11
|
+
vi.mock("@agent-native/core/server", async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof import("@agent-native/core/server")>()),
|
|
13
|
+
readOrgMemberRole: (...args: unknown[]) => mocks.readOrgMemberRole(...args),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
16
|
vi.mock("@agent-native/core/settings", () => ({
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
|
|
30
31
|
beforeEach(() => {
|
|
31
32
|
vi.clearAllMocks();
|
|
32
|
-
mocks.
|
|
33
|
+
mocks.readOrgMemberRole.mockResolvedValue("owner");
|
|
33
34
|
});
|
|
34
35
|
|
|
35
36
|
describe("normalizeMcpAppAccessSettings", () => {
|
|
@@ -63,7 +64,7 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
63
64
|
it.each(["owner", "admin"])(
|
|
64
65
|
"lets an organization %s change the app grants",
|
|
65
66
|
async (role) => {
|
|
66
|
-
mocks.
|
|
67
|
+
mocks.readOrgMemberRole.mockResolvedValue(role);
|
|
67
68
|
|
|
68
69
|
const result = await runWithRequestContext(
|
|
69
70
|
{ userEmail: "manager@example.test", orgId: "org-1" },
|
|
@@ -74,10 +75,10 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
74
75
|
}),
|
|
75
76
|
);
|
|
76
77
|
|
|
77
|
-
expect(mocks.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
79
|
+
"org-1",
|
|
80
|
+
"manager@example.test",
|
|
81
|
+
);
|
|
81
82
|
expect(mocks.putOrgSetting).toHaveBeenCalledWith(
|
|
82
83
|
"org-1",
|
|
83
84
|
"dispatch-mcp-app-access",
|
|
@@ -92,7 +93,7 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
92
93
|
);
|
|
93
94
|
|
|
94
95
|
it("rejects an organization member without changing grants", async () => {
|
|
95
|
-
mocks.
|
|
96
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
96
97
|
|
|
97
98
|
await expect(
|
|
98
99
|
runWithRequestContext(
|
|
@@ -109,8 +110,34 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
109
110
|
expect(mocks.putOrgSetting).not.toHaveBeenCalled();
|
|
110
111
|
});
|
|
111
112
|
|
|
113
|
+
it("caps a delegated owner before changing organization app grants", async () => {
|
|
114
|
+
mocks.readOrgMemberRole.mockResolvedValue("member");
|
|
115
|
+
|
|
116
|
+
await expect(
|
|
117
|
+
runWithRequestContext(
|
|
118
|
+
{
|
|
119
|
+
userEmail: "owner@example.test",
|
|
120
|
+
orgId: "org-1",
|
|
121
|
+
a2aIdentityAssurance: "org-member",
|
|
122
|
+
},
|
|
123
|
+
() =>
|
|
124
|
+
setDispatchMcpAppAccessSettings({
|
|
125
|
+
mode: "selected-apps",
|
|
126
|
+
selectedAppIds: ["analytics"],
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
).rejects.toMatchObject({ statusCode: 403 });
|
|
130
|
+
expect(mocks.readOrgMemberRole).toHaveBeenCalledWith(
|
|
131
|
+
"org-1",
|
|
132
|
+
"owner@example.test",
|
|
133
|
+
);
|
|
134
|
+
expect(mocks.putOrgSetting).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
112
137
|
it("fails closed when organization membership cannot be verified", async () => {
|
|
113
|
-
mocks.
|
|
138
|
+
mocks.readOrgMemberRole.mockRejectedValue(
|
|
139
|
+
new Error("org lookup unavailable"),
|
|
140
|
+
);
|
|
114
141
|
|
|
115
142
|
await expect(
|
|
116
143
|
runWithRequestContext(
|
|
@@ -132,7 +159,7 @@ describe("setDispatchMcpAppAccessSettings", () => {
|
|
|
132
159
|
}),
|
|
133
160
|
);
|
|
134
161
|
|
|
135
|
-
expect(mocks.
|
|
162
|
+
expect(mocks.readOrgMemberRole).not.toHaveBeenCalled();
|
|
136
163
|
expect(mocks.putUserSetting).toHaveBeenCalledWith(
|
|
137
164
|
"solo@example.test",
|
|
138
165
|
"dispatch-mcp-app-access",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { getDbExec } from "@agent-native/core/db";
|
|
2
1
|
import {
|
|
3
2
|
getRequestOrgId,
|
|
4
3
|
getRequestUserEmail,
|
|
4
|
+
readOrgMemberRole,
|
|
5
5
|
} from "@agent-native/core/server";
|
|
6
6
|
import {
|
|
7
7
|
getOrgSetting,
|
|
@@ -78,16 +78,9 @@ function currentAccessScope(): AccessScope {
|
|
|
78
78
|
async function assertCanManageMcpAppAccess(scope: AccessScope): Promise<void> {
|
|
79
79
|
if (scope.kind === "user") return;
|
|
80
80
|
|
|
81
|
-
let role:
|
|
81
|
+
let role: string | null = null;
|
|
82
82
|
try {
|
|
83
|
-
|
|
84
|
-
sql: `SELECT role FROM org_members
|
|
85
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
86
|
-
AND federation_removal_pending_at IS NULL
|
|
87
|
-
LIMIT 1`,
|
|
88
|
-
args: [scope.id, scope.actor.toLowerCase()],
|
|
89
|
-
});
|
|
90
|
-
role = rows[0]?.role;
|
|
83
|
+
role = await readOrgMemberRole(scope.id, scope.actor);
|
|
91
84
|
} catch {
|
|
92
85
|
// Fail closed when org membership cannot be verified.
|
|
93
86
|
}
|
|
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
6
6
|
createDbExec: vi.fn(),
|
|
7
7
|
orgId: null as string | null,
|
|
8
8
|
ownerEmail: "owner@example.com",
|
|
9
|
+
delegatedOrgMember: false,
|
|
9
10
|
dispatchConfig: {
|
|
10
11
|
adminEmails: [] as string[],
|
|
11
12
|
workspaceOwnerEmails: [] as string[],
|
|
@@ -20,10 +21,29 @@ vi.mock("@agent-native/core/db", () => ({
|
|
|
20
21
|
}));
|
|
21
22
|
|
|
22
23
|
vi.mock("@agent-native/core/server", () => ({
|
|
24
|
+
getRequestContext: () =>
|
|
25
|
+
mocks.delegatedOrgMember
|
|
26
|
+
? {
|
|
27
|
+
a2aIdentityAssurance: "org-member",
|
|
28
|
+
userEmail: mocks.ownerEmail,
|
|
29
|
+
orgId: mocks.orgId,
|
|
30
|
+
}
|
|
31
|
+
: undefined,
|
|
23
32
|
getAppConfig: () => ({
|
|
24
33
|
dispatch: mocks.dispatchConfig,
|
|
25
34
|
observability: { superOrgId: mocks.superOrgId },
|
|
26
35
|
}),
|
|
36
|
+
readOrgMemberRole: async (orgId: string, email: string) => {
|
|
37
|
+
const result = await mocks.currentExecute({
|
|
38
|
+
sql: "SELECT role FROM org_members",
|
|
39
|
+
args: [orgId, email.toLowerCase()],
|
|
40
|
+
});
|
|
41
|
+
const role = result.rows[0]?.role;
|
|
42
|
+
if (mocks.delegatedOrgMember && (role === "owner" || role === "admin")) {
|
|
43
|
+
return "member";
|
|
44
|
+
}
|
|
45
|
+
return typeof role === "string" ? role : null;
|
|
46
|
+
},
|
|
27
47
|
}));
|
|
28
48
|
|
|
29
49
|
vi.mock("./dispatch-store.js", () => ({
|
|
@@ -114,6 +134,7 @@ describe("thread-debug-store", () => {
|
|
|
114
134
|
beforeEach(() => {
|
|
115
135
|
mocks.orgId = null;
|
|
116
136
|
mocks.ownerEmail = "owner@example.com";
|
|
137
|
+
mocks.delegatedOrgMember = false;
|
|
117
138
|
mocks.dispatchConfig.adminEmails = [];
|
|
118
139
|
mocks.dispatchConfig.workspaceOwnerEmails = [];
|
|
119
140
|
mocks.dispatchConfig.defaultOwnerEmail = undefined;
|
|
@@ -458,6 +479,27 @@ describe("thread-debug-store", () => {
|
|
|
458
479
|
expect(result.thread.ownerEmail).toBe("outsider@example.com");
|
|
459
480
|
});
|
|
460
481
|
|
|
482
|
+
it("scopes a delegated owner assertion to its own transcripts", async () => {
|
|
483
|
+
mocks.orgId = "org-1";
|
|
484
|
+
mocks.ownerEmail = "owner@example.com";
|
|
485
|
+
mocks.delegatedOrgMember = true;
|
|
486
|
+
mocks.currentExecute.mockImplementation(async ({ sql, args }) => {
|
|
487
|
+
if (sql.includes("SELECT role FROM org_members")) {
|
|
488
|
+
return { rows: [{ role: "owner" }] };
|
|
489
|
+
}
|
|
490
|
+
if (sql.includes("FROM chat_threads")) {
|
|
491
|
+
expect(sql).toContain("owner_email = ?");
|
|
492
|
+
expect(args).toContain("owner@example.com");
|
|
493
|
+
return { rows: [] };
|
|
494
|
+
}
|
|
495
|
+
return { rows: [] };
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
await expect(
|
|
499
|
+
getAgentThreadDebug({ threadId: "other-members-thread" }),
|
|
500
|
+
).rejects.toMatchObject({ statusCode: 404 });
|
|
501
|
+
});
|
|
502
|
+
|
|
461
503
|
it("keeps explicit remote sources admin-only", async () => {
|
|
462
504
|
vi.stubEnv("REMOTE_DATABASE_URL", "postgres://remote/db");
|
|
463
505
|
|
|
@@ -471,6 +513,28 @@ describe("thread-debug-store", () => {
|
|
|
471
513
|
expect(mocks.createDbExec).not.toHaveBeenCalled();
|
|
472
514
|
});
|
|
473
515
|
|
|
516
|
+
it("does not let an environment admin assertion inspect remote transcripts", async () => {
|
|
517
|
+
mocks.orgId = "org-1";
|
|
518
|
+
mocks.ownerEmail = "platform-admin@example.com";
|
|
519
|
+
mocks.delegatedOrgMember = true;
|
|
520
|
+
mocks.dispatchConfig.adminEmails = ["platform-admin@example.com"];
|
|
521
|
+
mocks.currentExecute.mockImplementation(async ({ sql }) =>
|
|
522
|
+
sql.includes("SELECT role FROM org_members")
|
|
523
|
+
? { rows: [{ role: "owner" }] }
|
|
524
|
+
: { rows: [] },
|
|
525
|
+
);
|
|
526
|
+
vi.stubEnv("REMOTE_DATABASE_URL", "postgres://remote/db");
|
|
527
|
+
|
|
528
|
+
await expect(
|
|
529
|
+
listAgentRunFailures({ sourceId: "remote" }),
|
|
530
|
+
).rejects.toMatchObject({
|
|
531
|
+
statusCode: 403,
|
|
532
|
+
message:
|
|
533
|
+
"Only Dispatch admins can inspect thread databases from other apps.",
|
|
534
|
+
});
|
|
535
|
+
expect(mocks.createDbExec).not.toHaveBeenCalled();
|
|
536
|
+
});
|
|
537
|
+
|
|
474
538
|
it("prevents organization admins from requesting an owner outside their organization", async () => {
|
|
475
539
|
mocks.orgId = "org-1";
|
|
476
540
|
mocks.currentExecute.mockImplementation(async ({ sql }) => {
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type AgentFailureRegime,
|
|
5
5
|
} from "@agent-native/core/agent/engine";
|
|
6
6
|
import { createDbExec, getDbExec, type DbExec } from "@agent-native/core/db";
|
|
7
|
-
import { getAppConfig } from "@agent-native/core/server";
|
|
7
|
+
import { getAppConfig, readOrgMemberRole } from "@agent-native/core/server";
|
|
8
8
|
import { ForbiddenError } from "@agent-native/core/sharing";
|
|
9
9
|
|
|
10
10
|
import {
|
|
@@ -436,14 +436,7 @@ async function viewerOrgRole(
|
|
|
436
436
|
viewerEmail: string,
|
|
437
437
|
): Promise<string | null> {
|
|
438
438
|
if (!orgId) return null;
|
|
439
|
-
|
|
440
|
-
`SELECT role FROM org_members
|
|
441
|
-
WHERE org_id = ? AND LOWER(email) = ?
|
|
442
|
-
AND federation_removal_pending_at IS NULL
|
|
443
|
-
LIMIT 1`,
|
|
444
|
-
[orgId, viewerEmail.toLowerCase()],
|
|
445
|
-
);
|
|
446
|
-
return typeof rows[0]?.role === "string" ? rows[0].role : null;
|
|
439
|
+
return readOrgMemberRole(orgId, viewerEmail);
|
|
447
440
|
}
|
|
448
441
|
|
|
449
442
|
async function currentOrgMembers(orgId: string | null): Promise<string[]> {
|