Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
64178c6
Add Agent Network Admin and Usage Viewer roles, gate Agent Network pe…
mlsmaycon Aug 16, 2026
b8462cc
Gate Usage and Clusters tabs by their own permissions
mlsmaycon Aug 16, 2026
6c9c73a
Fall back to a known configuration tab for unknown query values
mlsmaycon Aug 16, 2026
fe96e56
Derive the configuration tab from the URL query
mlsmaycon Aug 16, 2026
eff4321
Derive the usage tab from the URL query
mlsmaycon Aug 16, 2026
3fbeb1e
Group user roles into General and Agent Network tabs
braginini Aug 16, 2026
f75324f
Point agent network learn-more links at their docs pages
braginini Aug 16, 2026
64b76d1
Skip the domains lookup when the role can't read it
braginini Aug 17, 2026
654c4eb
Flatten the role selector and add a caller-scoped My Setup page
mlsmaycon Aug 18, 2026
0ae1aab
Fix delegated-role API errors and reuse the usage overview for My Usage
mlsmaycon Aug 18, 2026
183e59a
Reuse the providers-page agent config on My Setup and self-scope Usag…
mlsmaycon Aug 18, 2026
df2ad79
Hide provider write actions from read-only viewers
mlsmaycon Aug 18, 2026
684da5c
Restore the grouped role selector tabs
mlsmaycon Aug 22, 2026
c20d6ff
Merge remote-tracking branch 'origin/main' into agent-network-roles
mlsmaycon Aug 25, 2026
d2b3ea5
Offer the provider and model filters to self-scoped callers
mlsmaycon Aug 27, 2026
751cbdf
Give the agent config a single home on Connect Agent
braginini Aug 31, 2026
3eb77fc
Simplify Bedrock config
braginini Aug 31, 2026
d091fa6
Inline the agent config on the Connect page
braginini Aug 31, 2026
c11728a
Show a policy's allowed models in the policies table
braginini Aug 31, 2026
5251e46
Point the Kimi e2e config assertions at the Connect Agent page
mlsmaycon Aug 31, 2026
91ed66c
Hand the agent config to every account member
braginini Aug 31, 2026
ec52828
Merge remote-tracking branch 'origin/agent-network-roles' into agent-…
braginini Aug 31, 2026
da1c8a2
Let the surface switch decide the Agent Network section
braginini Aug 31, 2026
7b59034
Merge remote-tracking branch 'origin/main' into agent-network-roles
braginini Aug 31, 2026
78a1053
Collapse the two User types the merge left behind
braginini Aug 31, 2026
ba8b895
Fix SNAPSHOT version display
braginini Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 145 additions & 13 deletions e2e/helpers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ async function getApiContext(
]);

const request = response.request();
const authHeader =
(await request.allHeaders())["authorization"] || "";
const authHeader = (await request.allHeaders())["authorization"] || "";
const token = authHeader.replace("Bearer ", "");
const url = new URL(request.url());
const origin = `${url.protocol}//${url.host}`;
Expand Down Expand Up @@ -76,15 +75,35 @@ async function apiDelete(page: Page, path: string): Promise<void> {
});
}

async function apiPost<T>(page: Page, path: string, data: unknown): Promise<T> {
async function apiPost<T>(page: Page, path: string, body: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.post(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data,
data: body,
});
if (!resp.ok()) {
throw new Error(
`POST ${path} returned ${resp.status()}: ${await resp.text()}`,
);
}
return resp.json();
}

async function apiPut<T>(page: Page, path: string, body: unknown): Promise<T> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.put(`${origin}/api${path}`, {
headers: { Authorization: `Bearer ${token}` },
data: body,
});
if (!resp.ok()) {
throw new Error(
`PUT ${path} returned ${resp.status()}: ${await resp.text()}`,
);
}
return resp.json();
}

/** List all groups. */
export async function listGroups(page: Page): Promise<Group[]> {
return apiGet<Group[]>(page, "/groups");
}
Expand All @@ -108,10 +127,12 @@ export async function deletePeersByPrefix(page: Page, prefix: string) {
}
}

/** Create a group by name. */
export async function createGroup(page: Page, name: string): Promise<Group> {
return apiPost<Group>(page, "/groups", { name, peers: [] });
}

/** Delete a group by ID. */
export async function deleteGroup(page: Page, groupId: string) {
await apiDelete(page, `/groups/${groupId}`);
}
Expand Down Expand Up @@ -259,7 +280,10 @@ export async function deleteRouteById(page: Page, routeId: string) {
await apiDelete(page, `/routes/${routeId}`);
}

export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
export async function deleteRoutesByNetworkIdPrefix(
page: Page,
prefix: string,
) {
const routes = await listRoutes(page);
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
for (const r of toDelete) {
Expand Down Expand Up @@ -338,8 +362,13 @@ type NotificationChannel = {
enabled: boolean;
};

export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
export async function listNotificationChannels(
page: Page,
): Promise<NotificationChannel[]> {
return apiGet<NotificationChannel[]>(
page,
"/integrations/notifications/channels",
);
}

export async function deleteNotificationChannel(page: Page, channelId: string) {
Expand All @@ -353,7 +382,10 @@ export async function deleteAllNotificationChannels(page: Page) {
}
}

export async function deleteNotificationChannelsByType(page: Page, type: string) {
export async function deleteNotificationChannelsByType(
page: Page,
type: string,
) {
const channels = await listNotificationChannels(page);
const toDelete = channels.filter((c) => c.type === type);
for (const c of toDelete) {
Expand All @@ -368,15 +400,20 @@ type NameserverGroup = {
name: string;
};

export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
export async function listNameserverGroups(
page: Page,
): Promise<NameserverGroup[]> {
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
}

export async function deleteNameserverGroupById(page: Page, id: string) {
await apiDelete(page, `/dns/nameservers/${id}`);
}

export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
export async function deleteNameserverGroupsByPrefix(
page: Page,
prefix: string,
) {
const groups = await listNameserverGroups(page);
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
for (const g of toDelete) {
Expand All @@ -391,11 +428,16 @@ type ReverseProxyService = {
name: string;
};

export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
export async function listReverseProxyServices(
page: Page,
): Promise<ReverseProxyService[]> {
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
}

export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
export async function deleteReverseProxyServiceById(
page: Page,
serviceId: string,
) {
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
}

Expand Down Expand Up @@ -449,7 +491,13 @@ export async function waitForProxyClustersOnline(
throw new Error(
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
", ",
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
)}; got ${JSON.stringify(
last.map((c) => ({
a: c.address,
online: c.online,
n: c.connected_proxies,
})),
)}`,
);
}

Expand All @@ -461,6 +509,8 @@ type User = {
name: string;
role: string;
status: string;
auto_groups: string[];
is_blocked: boolean;
is_current: boolean;
};

Expand Down Expand Up @@ -538,3 +588,85 @@ export async function supportsAgentNetworkSettingsBootstrap(
!!settings && typeof settings === "object" && "proxy_address" in settings
);
}

/**
* Whether the management build under test serves the caller-scoped
* GET /agent-network/agent-config answer that backs the Connect Agent page.
* The endpoint answers 200 for every authenticated caller (an unconfigured
* caller gets configured=false, never an error), so any non-OK status means
* the build predates it.
*/
export async function supportsAgentNetworkAgentConfig(
page: Page,
): Promise<boolean> {
const { token, origin } = await getApiContext(page);
const resp = await page.request.get(
`${origin}/api/agent-network/agent-config`,
{ headers: { Authorization: `Bearer ${token}` } },
);
return resp.ok();
}

type AgentNetworkPolicy = {
id: string;
name: string;
};

/** List Agent Network policies. */
export async function listAgentNetworkPolicies(
page: Page,
): Promise<AgentNetworkPolicy[]> {
return apiGet<AgentNetworkPolicy[]>(page, "/agent-network/policies");
}

/** Create an Agent Network policy. */
export async function createAgentNetworkPolicy(
page: Page,
body: {
name: string;
source_groups: string[];
destination_provider_ids: string[];
enabled?: boolean;
},
): Promise<AgentNetworkPolicy> {
return apiPost<AgentNetworkPolicy>(page, "/agent-network/policies", {
enabled: true,
...body,
});
}

/** Delete all Agent Network policies whose name starts with the prefix. */
export async function deleteAgentNetworkPoliciesByPrefix(
page: Page,
prefix: string,
) {
const policies = await listAgentNetworkPolicies(page);
for (const p of policies) {
if (p.name.startsWith(prefix)) {
await apiDelete(page, `/agent-network/policies/${p.id}`);
}
}
}

/** The user the captured token belongs to. */
export async function getCurrentUser(page: Page): Promise<User> {
const users = await apiGet<User[]>(page, "/users");
const current = users.find((u) => u.is_current);
if (!current) {
throw new Error("no is_current user in the /users answer");
}
return current;
}

/** Replace a user's auto-groups, keeping role and blocked state. */
export async function updateUserAutoGroups(
page: Page,
user: User,
autoGroups: string[],
): Promise<void> {
await apiPut(page, `/users/${user.id}`, {
role: user.role,
auto_groups: autoGroups,
is_blocked: !!user.is_blocked,
});
}
93 changes: 78 additions & 15 deletions e2e/tests/agent-network-kimi-provider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
*
* Walks the Kimi provider lifecycle end to end against the real backend:
* pick kimi_api from the catalog (prefilled host, catalog models with
* pricing), connect it, then verify the Kimi-gated config surfaces in the
* "Configure Your Agent" modal (Kimi CLI tab, Kimi backend option in the
* Claude Code tab) that only render when a Kimi provider is connected.
* pricing), connect it, then verify the Kimi-gated config surfaces on the
* Connect Agent page (Kimi CLI tab, Kimi backend option in the Claude Code
* tab) that only render when a Kimi provider is connected.
*
* The kimi_api catalog entry ships with newer management builds. When the
* backend under test predates it, the whole suite skips instead of failing —
Expand All @@ -19,20 +19,57 @@
* localStorage override (see testAgentNetworkOverride in utils/netbird.ts),
* set via addInitScript on a dedicated context below.
*/
import { test, expect, type Browser, type Page } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";
import { type Browser, expect, type Page,test } from "@playwright/test";
import {
createAgentNetworkPolicy,
createGroup,
deleteAgentNetworkPoliciesByPrefix,
deleteAgentNetworkProvidersByPrefix,
deleteGroup,
getCurrentUser,
listAgentNetworkCatalog,
listGroups,
supportsAgentNetworkAgentConfig,
supportsAgentNetworkSettingsBootstrap,
updateUserAutoGroups,
} from "../helpers/api";
import { loginToApp } from "../helpers/auth";
import { generateRandomName } from "../helpers/utils";

const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
const KIMI_CATALOG_ID = "kimi_api";
const KIMI_CATALOG_NAME = "Kimi (Moonshot AI) API";
const PROVIDER_PREFIX = "e2e-kimi-";

/**
* Remove every fixture this spec creates: the policy granting the caller the
* provider, the group carrying that grant (detached from the caller first —
* a group referenced by a user's auto-groups refuses deletion), and the
* provider itself. Runs before the test too, so leftovers from an interrupted
* run never poison the next one.
*/
async function cleanupKimiFixtures(page: Page) {
await deleteAgentNetworkPoliciesByPrefix(page, PROVIDER_PREFIX);
const fixtureGroups = (await listGroups(page)).filter((g) =>
g.name.startsWith(PROVIDER_PREFIX),
);
if (fixtureGroups.length > 0) {
const fixtureGroupIds = new Set(fixtureGroups.map((g) => g.id));
const caller = await getCurrentUser(page);
if ((caller.auto_groups ?? []).some((id) => fixtureGroupIds.has(id))) {
await updateUserAutoGroups(
page,
caller,
(caller.auto_groups ?? []).filter((id) => !fixtureGroupIds.has(id)),
);
}
for (const g of fixtureGroups) {
await deleteGroup(page, g.id);
}
}
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
}

async function newAgentNetworkPage(browser: Browser): Promise<{
page: Page;
close: () => Promise<void>;
Expand Down Expand Up @@ -71,8 +108,17 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
"management build has no POST /agent-network/settings, so the " +
"wizard cannot bootstrap the account before the first create",
);
// The Kimi-gated config surfaces live on the Connect Agent page, whose
// caller-scoped GET /agent-network/agent-config answer ships with
// newer management builds. The suite starts running once the backend
// under test carries it.
test.skip(
!(await supportsAgentNetworkAgentConfig(page)),
"management build has no GET /agent-network/agent-config, so the " +
"Connect Agent page cannot render the agent config",
);

await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await cleanupKimiFixtures(page);

await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
Expand Down Expand Up @@ -153,17 +199,36 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
.click({ force: true });
const created = await Promise.race([createResponse, bootstrapRejected]);
expect([200, 201]).toContain(created.status());
const createdProvider = (await created.json()) as { id: string };

// Row lands in the providers table.
await expect(page.getByText(providerName).first()).toBeVisible();

// ---- Kimi-gated agent config surfaces ----
await page
.getByRole("button", { name: "Agent Config" })
.click({ force: true });
// The agent config lives inline on the Connect Agent page — the
// providers page keeps only the endpoint URL and Copy. That page's
// answer is caller-scoped: it offers only providers the caller's own
// policies authorize, so grant the caller the new provider through a
// dedicated group + policy (removed again by cleanupKimiFixtures).
const caller = await getCurrentUser(page);
const grantGroup = await createGroup(page, `${PROVIDER_PREFIX}grant`);
await updateUserAutoGroups(page, caller, [
...(caller.auto_groups ?? []),
grantGroup.id,
]);
await createAgentNetworkPolicy(page, {
name: `${PROVIDER_PREFIX}policy`,
source_groups: [grantGroup.id],
destination_provider_ids: [createdProvider.id],
});

await page.goto("/agent-network/connect");

// Kimi CLI tab only renders when a kimi_api provider is connected.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible();
// Kimi CLI tab only renders when a kimi_api provider is connected. The
// longer timeout covers the page's caller-scoped agent-config fetch.
await expect(page.getByRole("tab", { name: "Kimi CLI" })).toBeVisible({
timeout: 15_000,
});

// Claude Code tab's backend dropdown offers (and, with Kimi as the only
// Anthropic-shaped provider, pre-selects) Kimi — its settings.json
Expand Down Expand Up @@ -192,10 +257,8 @@ test.describe.serial("Agent Network Kimi provider @agent-network", () => {
await expect(page.getByText('wire_api = "responses"')).toBeVisible();
await expect(page.getByText('wire_api = "chat"')).not.toBeVisible();

await page.keyboard.press("Escape");

// ---- cleanup ----
await deleteAgentNetworkProvidersByPrefix(page, PROVIDER_PREFIX);
await cleanupKimiFixtures(page);
} finally {
await close();
}
Expand Down
Loading
Loading