Compare commits

...
Author SHA1 Message Date
Claude ca7ae3f924 Pick a private-capable proxy cluster when bootstrapping the agent network
The connect-provider wizard bootstraps the account's agent network
endpoint from a proxy cluster it auto-picks off /reverse-proxies/domains,
taking the first validated free domain. That list includes clusters that
cannot host the endpoint: the endpoint is a private service, reachable
only from connected peers and authorised by their tunnel identity, which
needs a cluster with at least one connected embedded proxy — the same
supports_private flag the Reverse Proxy modal gates NetBird-Only Access
on. Management refuses a bootstrap onto a cluster reporting it false, so
the wizard was able to propose a pick the API rejects, and the endpoint
it assigns is immutable.

Filter the candidates on supports_private before auto-picking, and give
the "nothing to pick" callout a second message for the case where
clusters exist but none of them has an embedded proxy — a different
problem from having no proxy at all, and a different fix.

Only an explicit false disqualifies a cluster: a management build that
predates the flag reports nothing at all, and dropping every cluster
there would block setup on a backend that would have accepted it. That
matches how the server reads an unreported capability.

Callout takes an optional data-testid so a spec can assert on the state
it announces rather than on its copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:56:32 +00:00
3 changed files with 246 additions and 8 deletions

No files matched your search

@@ -0,0 +1,196 @@
/**
* Agent Network bootstrap-cluster spec.
*
* The account's agent network endpoint is assigned once, from the proxy
* cluster the connect-provider wizard pins it to, and it is immutable after
* that. Not every live cluster can host it: the endpoint is a private service,
* reachable only from connected peers and authorised by their tunnel identity,
* which only a cluster with a connected embedded proxy (`netbird proxy`) can
* serve. Management reports that per cluster as supports_private and refuses a
* bootstrap onto a cluster without it.
*
* This spec pins the wizard's half of that contract: it picks a
* private-capable cluster even when a centralised one is listed first, and it
* blocks setup — with a message naming what is missing — when no listed
* cluster qualifies. Both the settings and domains routes are mocked, so the
* assertions do not depend on which clusters the test backend happens to have
* connected, and no provider is created against it.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
// bug this spec guards against.
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
// Listed second, and the only valid pick.
const EMBEDDED_CLUSTER = "embedded.proxy.netbird.io";
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
...UNBOOTSTRAPPED_DEFAULTS,
endpoint: `violet.${EMBEDDED_CLUSTER}`,
proxy_address: EMBEDDED_CLUSTER,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
function freeDomain(name: string, supportsPrivate: boolean) {
return {
id: name,
domain: name,
validated: true,
type: "free",
supports_private: supportsPrivate,
supports_custom_ports: true,
require_subdomain: true,
};
}
async function json(route: import("@playwright/test").Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Opens the providers page for an account that has not been bootstrapped,
* with the given clusters on the domains endpoint. Returns the page plus a
* promise-friendly getter for the bootstrap POST body, which is captured and
* answered locally so the real backend is never mutated.
*/
async function openWizard(
browser: Browser,
domains: ReturnType<typeof freeDomain>[],
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await page.route("**/api/agent-network/settings", (route) => {
const method = route.request().method();
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
return route.continue();
});
await page.route("**/api/reverse-proxies/domains", (route) =>
route.request().method() === "GET"
? json(route, domains)
: route.continue(),
);
// Keep the create hermetic: the wizard only reaches this once the
// bootstrap it does first has succeeded.
await page.route("**/api/agent-network/providers", (route) =>
route.request().method() === "POST"
? json(route, { id: "prov-e2e", name: "e2e-provider" })
: route.continue(),
);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
/** Fills the provider tab with the minimum a create needs. */
async function fillProviderTab(page: Page) {
await page.getByPlaceholder("https://api.openai.com").fill("https://api.openai.com");
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
}
test.describe("Agent Network bootstrap cluster @agent-network", () => {
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
freeDomain(EMBEDDED_CLUSTER, true),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The unusable cluster is listed, but it is not a blocker: a usable one
// exists, so the wizard proceeds without the warning.
await expect(
page.getByTestId("agent-network-no-cluster-callout"),
).toHaveCount(0);
await fillProviderTab(page);
const bootstrap = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page.getByRole("button", { name: "Continue" }).click({ force: true });
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const payload = (await bootstrap).request().postDataJSON();
expect(payload.proxy_address).toBe(EMBEDDED_CLUSTER);
} finally {
await close();
}
});
test("blocks setup when no listed cluster has an embedded proxy", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const callout = page.getByTestId("agent-network-no-cluster-callout");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toContainText("embedded proxy");
// With nothing to pin to there is no bootstrap to make, so the wizard
// cannot advance — rather than sending a request management refuses.
await fillProviderTab(page);
await expect(
page.getByRole("button", { name: "Continue" }),
).toBeDisabled();
} finally {
await close();
}
});
});
+8 -1
View File
@@ -9,6 +9,9 @@ type Props = {
icon?: React.ReactNode;
children?: React.ReactNode;
className?: string;
/** Lets callers mark a callout so tests can assert on the state it
* announces rather than on its copy. */
"data-testid"?: string;
} & CalloutVariants;
export const calloutVariants = cva(
@@ -31,9 +34,13 @@ export const Callout = ({
icon = <InfoIcon size={14} className={"shrink-0 relative top-[3px]"} />,
className,
variant = "default",
"data-testid": dataTestId,
}: Props) => {
return (
<div className={cn(calloutVariants({ variant }), className)}>
<div
className={cn(calloutVariants({ variant }), className)}
data-testid={dataTestId}
>
{icon}
<div>{children}</div>
</div>
+42 -7
View File
@@ -337,23 +337,46 @@ export default function AIProviderModal({
),
[domains],
);
// Not every live cluster can host the endpoint. The agent network gateway is
// a private service — reachable only from connected peers, authenticated by
// their tunnel identity — which only a proxy running embedded in a netbird
// client (`netbird proxy`) can serve. supports_private is the flag for that,
// the same one the Reverse Proxy modal gates NetBird-Only Access on, and
// management refuses a bootstrap onto a cluster reporting it false. Picking
// from the filtered list keeps the wizard from proposing a cluster the API
// rejects — and the endpoint it assigns is immutable, so a wrong pick is not
// something the operator can edit away afterwards.
//
// Only an explicit false disqualifies a cluster: a management build that
// predates the flag reports nothing at all, and dropping every cluster there
// would block setup on a backend that would have accepted it — the same
// "nothing to judge" reading the server applies to an unreported capability.
const bootstrapClusters = useMemo(
() => validatedClusters.filter((d) => d.supports_private !== false),
[validatedClusters],
);
// Wait for both requests before claiming there is nothing to pick, otherwise
// the warning flashes while the settings row is still loading.
const noClustersAvailable =
!settingsBootstrapped &&
!settingsLoading &&
!domainsLoading &&
validatedClusters.length === 0;
bootstrapClusters.length === 0;
// Clusters exist, but every one of them reported having no embedded proxy:
// a different problem from having no proxy at all, and a different fix, so
// it gets its own message rather than "connect a proxy".
const clustersLackEmbeddedProxy =
noClustersAvailable && validatedClusters.length > 0;
// Auto-pick the first validated cluster on first render once the
// Auto-pick the first usable cluster on first render once the
// /domains response lands. Only matters for the first-create flow —
// once settings is bootstrapped no further bootstrap happens.
React.useEffect(() => {
if (settingsBootstrapped) return;
if (bootstrapCluster) return;
if (validatedClusters.length === 0) return;
setBootstrapCluster(validatedClusters[0].domain);
}, [settingsBootstrapped, bootstrapCluster, validatedClusters]);
if (bootstrapClusters.length === 0) return;
setBootstrapCluster(bootstrapClusters[0].domain);
}, [settingsBootstrapped, bootstrapCluster, bootstrapClusters]);
// Seed the upstream URL from the catalog entry once it lands — the
// catalog is fetched async, so on first render `getById("openai_api")`
@@ -412,7 +435,7 @@ export default function AIProviderModal({
);
setApiKey("");
setBootstrapCluster(
settingsBootstrapped ? "" : validatedClusters[0]?.domain ?? "",
settingsBootstrapped ? "" : bootstrapClusters[0]?.domain ?? "",
);
setModels([]);
setExtraValues({});
@@ -763,6 +786,7 @@ export default function AIProviderModal({
<div className={"px-8 pt-3 flex-col flex gap-6"}>
{noClustersAvailable && (
<Callout
data-testid="agent-network-no-cluster-callout"
variant={"warning"}
icon={
<AlertCircleIcon
@@ -771,7 +795,18 @@ export default function AIProviderModal({
/>
}
>
{canReadDomains ? (
{canReadDomains && clustersLackEmbeddedProxy ? (
<>
No proxy cluster can host the agent network endpoint. It
is reachable only from connected peers, which needs a
cluster with at least one connected embedded proxy (
<code>netbird proxy</code>). Connect one under
<InlineLink href={"/reverse-proxy/services"}>
{" "}Reverse Proxy
</InlineLink>
{" "}before adding a provider.
</>
) : canReadDomains ? (
<>
No active proxy clusters are available. Connect at least
one proxy under