From f87453c67d750e737e00649245da4ebdac8b4f9d Mon Sep 17 00:00:00 2001 From: Vaibhav Jain Date: Sat, 31 Oct 2026 08:31:25 +0530 Subject: [PATCH] Trust the deployment's own agent at exactly its configured URLs A Bot created in the app ("Run it here") is bound to the deployment's managed agent, MANAGED_AGENT_AG_UI_URL. A local deployment keeps that agent on loopback, and the endpoint check refused it as a private address, so every message to every such Bot failed with "will not dial". The refusal exists for addresses a person types in; this one is the deployment's own setting. checkAgentEndpoint and createAgentFetch now accept the deployment's own agent addresses - the managed Bot's URL, and the picked harness's only when an image is installed - and allow exactly those URLs, using the same identity the run path uses to decide whether the deployment token is attached (isManagedEndpoint). Another path on the same host and port is not the deployment's agent and stays refused, at registration, in the connection test, and on every redirect hop. The exemption only ever overrules a private-network refusal: the metadata address and non-web addresses stay refused whatever is configured. Fixes #888 --- server/src/agents/connection-test.ts | 8 ++ server/src/agents/endpoint.ts | 35 +++++++ server/src/agents/routes.ts | 8 ++ server/src/index.ts | 6 ++ server/tests/agent-endpoint.test.ts | 132 +++++++++++++++++++++++++-- 5 files changed, 180 insertions(+), 9 deletions(-) diff --git a/server/src/agents/connection-test.ts b/server/src/agents/connection-test.ts index 5648f78398..0e728d763e 100644 --- a/server/src/agents/connection-test.ts +++ b/server/src/agents/connection-test.ts @@ -140,6 +140,8 @@ export async function testAgentConnection( headers?: Record; allowPrivateHosts?: boolean; allowedHosts?: ReadonlySet; + /** The deployment's own agent addresses, reachable at exactly those URLs; see checkAgentEndpoint. */ + managedEndpoints?: readonly (string | URL)[]; fetchImpl?: typeof fetch; timeoutMs?: number; } = {}, @@ -149,6 +151,9 @@ export async function testAgentConnection( const verdict = checkAgentEndpoint(rawEndpoint, { allowPrivateHosts: options.allowPrivateHosts, ...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}), + ...(options.managedEndpoints + ? { managedEndpoints: options.managedEndpoints } + : {}), }); if (!verdict.allowed) return { ok: false, reason: verdict.reason }; @@ -156,6 +161,9 @@ export async function testAgentConnection( // Checking only what the person typed leaves the redirect as the way around it. const doFetch = createAgentFetch({ ...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}), + ...(options.managedEndpoints + ? { managedEndpoints: options.managedEndpoints } + : {}), ...(options.allowPrivateHosts !== undefined ? { allowPrivateHosts: options.allowPrivateHosts } : {}), diff --git a/server/src/agents/endpoint.ts b/server/src/agents/endpoint.ts index 133c00759e..88a6e71eb1 100644 --- a/server/src/agents/endpoint.ts +++ b/server/src/agents/endpoint.ts @@ -1,4 +1,5 @@ import { checkNavigationTarget } from "../computer/target"; +import { isManagedEndpoint } from "./managed-endpoint"; /** * Where an external agent lives, and whether we are willing to talk to it. @@ -71,6 +72,18 @@ export function checkAgentEndpoint( options: { allowPrivateHosts?: boolean; allowedHosts?: ReadonlySet; + /** + * The agent addresses this deployment runs itself: the managed Bot, and the harness picked + * during setup. + * + * These are the deployment's own settings, not addresses a person typed into a form, so the + * private-network refusal that guards registrations must not refuse them — a local deployment + * keeps its managed Bot on loopback, and a Bot created in the app is bound to exactly that + * address. The match is EXACT, on the same identity the run path uses to decide whether the + * deployment token is attached (`isManagedEndpoint`): another path on the same host and port + * is not the deployment's agent, and stays refused. + */ + managedEndpoints?: readonly (string | URL)[]; } = {}, ): EndpointVerdict { if (typeof raw !== "string" || !raw.trim()) { @@ -78,6 +91,20 @@ export function checkAgentEndpoint( } const verdict = checkNavigationTarget(raw.trim(), options); + if ( + !verdict.allowed && + options.managedEndpoints !== undefined && + isManagedEndpoint(raw.trim(), options.managedEndpoints) && + /* + * The same floor `namedAsAllowed` keeps: being the deployment's own address only ever + * overrules a refusal for being private. The metadata address and anything that is not a web + * address are refused before this, whatever the configuration says — a managed endpoint that + * IS one of those is a broken deployment, not a reason to dial it. + */ + checkNavigationTarget(raw.trim(), { allowPrivateHosts: true }).allowed + ) { + return { allowed: true, url: new URL(raw.trim()).toString() }; + } if (!verdict.allowed && namedAsAllowed(raw.trim(), options.allowedHosts)) { /* * Named, one host at a time, by whoever runs this deployment. @@ -258,6 +285,11 @@ export function createAgentFetch( * not be a way to arrive somewhere registration would have declined. */ allowedHosts?: ReadonlySet; + /** + * Carried to every hop, for the reason `allowedHosts` is: the deployment's own agent is + * reachable wherever it appears, and the match stays exact on every hop. + */ + managedEndpoints?: readonly (string | URL)[]; fetchImpl?: typeof fetch; /** * Told about every address this refused to dial, and why. @@ -293,6 +325,9 @@ export function createAgentFetch( ? { allowPrivateHosts: options.allowPrivateHosts } : {}), ...(options.allowedHosts ? { allowedHosts: options.allowedHosts } : {}), + ...(options.managedEndpoints + ? { managedEndpoints: options.managedEndpoints } + : {}), }); return async function guardedFetch(url: string, init?: RequestInit) { diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index b99b87c32d..a4ae4be976 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -51,6 +51,8 @@ export function parseAgentInput( allowPrivateHosts = false, /** Private addresses this deployment named as acceptable. Empty is the default posture. */ allowedHosts: ReadonlySet = new Set(), + /** The deployment's own agent addresses, reachable at exactly those URLs; see checkAgentEndpoint. */ + managedEndpoints?: readonly (string | URL)[], ): AgentInputParseResult { if (!isAgentInputObject(input)) { return { ok: false, error: "Agent input must be a JSON object." }; @@ -94,6 +96,7 @@ export function parseAgentInput( const verdict = checkAgentEndpoint(input.endpoint, { allowPrivateHosts, allowedHosts, + ...(managedEndpoints ? { managedEndpoints } : {}), }); if (!verdict.allowed) return { ok: false, error: verdict.reason }; endpoint = verdict.url; @@ -524,6 +527,9 @@ export function createAgentRoutes( ...(Object.keys(headers).length > 0 ? { headers } : {}), allowPrivateHosts, allowedHosts, + // Our own addresses are reachable at exactly their URLs: the test must not refuse the Bot + // this deployment started, and must not open anything else on its host. + ...(managedRun ? { managedEndpoints: managedRun.endpoints } : {}), }); // 200 either way: the request succeeded, and the verdict is the payload. A failed connection test // is an answer, not an error, and a 4xx here would have the surface render it as a broken button. @@ -612,6 +618,7 @@ export function createAgentRoutes( await context.req.json().catch(() => null), allowPrivateHosts, allowedHosts, + managedRun?.endpoints, ); if (!parsed.ok) return context.json({ error: parsed.error }, 400); if (parsed.value.isolated === true && !isolationAvailable) { @@ -682,6 +689,7 @@ export function createAgentRoutes( await context.req.json().catch(() => null), allowPrivateHosts, allowedHosts, + managedRun?.endpoints, ); if (!parsed.ok) return context.json({ error: parsed.error }, 400); if (parsed.value.isolated === true && !isolationAvailable) { diff --git a/server/src/index.ts b/server/src/index.ts index e33d2951f2..e6cd309b30 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1678,6 +1678,12 @@ const agentFetch = createAgentFetch({ allowPrivateHosts: config.computer?.allowPrivateHosts === true, // Named addresses are reachable on every hop, not only the one that was registered. allowedHosts: config.agentEndpointAllowedHosts, + // The deployment's own agent, at exactly its configured addresses: a local deployment keeps it + // on loopback, which the private-network check would otherwise refuse on every run. + managedEndpoints: [ + config.managedAgent?.endpoint, + config.managedAgent?.alsoRun, + ].filter((url): url is URL => url !== undefined), // The refusal is what the run already knows; this is what the deployment knows. Written here // rather than in `endpoint.ts` so that file keeps deciding and nothing else, the way the // target check it reuses does. diff --git a/server/tests/agent-endpoint.test.ts b/server/tests/agent-endpoint.test.ts index ad0cf63341..2aba3820c5 100644 --- a/server/tests/agent-endpoint.test.ts +++ b/server/tests/agent-endpoint.test.ts @@ -604,17 +604,131 @@ describe("dialling a stored agent endpoint", () => { const third = new Headers(calls[2]?.init.headers); expect(third.get("authorization")).toBeNull(); }); + + test("the deployment's own agent is dialled where the form could not register it", async () => { + // The managed Bot on a local deployment lives on loopback: the private-network check refuses + // it, and this deployment's own setting is the one address that refusal must not apply to. + const managed = ["http://127.0.0.1:41706/ag-ui"]; + const { calls, impl } = recorder([arrived]); + const dial = createAgentFetch({ + fetchImpl: impl, + managedEndpoints: managed, + }); + const response = await dial("http://127.0.0.1:41706/ag-ui", runRequest); + expect(response.status).toBe(200); + expect(calls.length).toBe(1); + }); + + test("a redirect to another path on the managed host is refused", async () => { + // The exemption is one exact URL, not the host and port: another service behind the same + // gateway stays refused, on a hop as much as at registration. + const managed = ["http://127.0.0.1:41706/ag-ui"]; + const refusals: Array<{ address: string; reason: string }> = []; + const { calls, impl } = recorder([ + redirectTo("http://127.0.0.1:41706/everything-else"), + arrived, + ]); + const dial = createAgentFetch({ + fetchImpl: impl, + managedEndpoints: managed, + onRefusal: (refusal) => refusals.push(refusal), + }); + await expect( + dial("https://agent.example.com/ag-ui", runRequest), + ).rejects.toThrow(/redirected/i); + expect(calls.length).toBe(1); + expect(refusals.length).toBe(1); + }); + + test("a redirect to the exact managed URL is followed", async () => { + const managed = ["http://127.0.0.1:41706/ag-ui"]; + const { calls, impl } = recorder([ + redirectTo("http://127.0.0.1:41706/ag-ui"), + arrived, + ]); + const dial = createAgentFetch({ + fetchImpl: impl, + managedEndpoints: managed, + }); + const response = await dial("https://agent.example.com/ag-ui", runRequest); + expect(response.status).toBe(200); + expect(calls.length).toBe(2); + }); +}); + +describe("the deployment's own agent addresses", () => { + // The managed Bot on a local deployment lives on loopback; the check must refuse a person who + // types a private address and must not refuse the address the deployment itself configured. + const managed = ["http://127.0.0.1:41706/ag-ui"]; + + test("the exact managed URL is allowed without the private-host opt-in", () => { + const verdict = checkAgentEndpoint("http://127.0.0.1:41706/ag-ui", { + managedEndpoints: managed, + }); + expect(verdict).toEqual({ + allowed: true, + url: "http://127.0.0.1:41706/ag-ui", + }); + }); + + test("a trailing slash is still the same address", () => { + // The run path attaches the deployment token by this same identity, so the two must agree. + expect( + checkAgentEndpoint("http://127.0.0.1:41706/ag-ui/", { + managedEndpoints: managed, + }).allowed, + ).toBeTrue(); + }); + + test("another path on the same host and port is refused", () => { + // The deployment's address is one URL, not the whole host:port. Anything wider and a shared + // internal gateway turns the exemption into one for every service behind it. + for (const address of [ + "http://127.0.0.1:41706/other", + "http://127.0.0.1:41706/ag-ui/admin", + "http://127.0.0.1:41706/", + ]) { + expect( + checkAgentEndpoint(address, { managedEndpoints: managed }).allowed, + ).toBeFalse(); + } + }); + + test("the same host on another port is refused", () => { + expect( + checkAgentEndpoint("http://127.0.0.1:9999/ag-ui", { + managedEndpoints: managed, + }).allowed, + ).toBeFalse(); + }); + + test("a managed URL with no explicit port matches itself", () => { + // URL parsing drops a scheme's default port, so the comparison has to be on the parsed + // identity, not on a host:port string: pinning "host:80" never matches what the parser + // reports as "host". + const internal = ["http://10.0.3.7/agent/ag-ui"]; + expect( + checkAgentEndpoint("http://10.0.3.7/agent/ag-ui", { + managedEndpoints: internal, + }).allowed, + ).toBeTrue(); + expect( + checkAgentEndpoint("http://10.0.3.7:80/agent/ag-ui", { + managedEndpoints: internal, + }).allowed, + ).toBeTrue(); + }); + + test("the metadata address is refused even when configured as managed", () => { + // Being the deployment's own address overrules a private-network refusal, nothing more. + expect( + checkAgentEndpoint("http://169.254.169.254/latest/meta-data/", { + managedEndpoints: ["http://169.254.169.254/latest/meta-data/"], + }).allowed, + ).toBeFalse(); + }); }); -/** - * Naming a private address instead of opening the network. - * - * `AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS` is a floor: it permits this deployment's whole network, to - * browsing and to agent endpoints alike, and a production deployment refuses to start with it on. - * That left bring-your-own-agent unusable in the image people are told to deploy, because a company's - * own agent lives at an internal address. Naming the address is the narrow answer, and these are the - * cases that decide whether it stays narrow. - */ describe("private addresses named one at a time", () => { const named = (...hosts: string[]) => new Set(hosts);