diff --git a/server/src/agents/connection-test.ts b/server/src/agents/connection-test.ts index 5648f7839..0e728d763 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 133c00759..88a6e71eb 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 b99b87c32..a4ae4be97 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 e33d2951f..e6cd309b3 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 ad0cf6334..2aba3820c 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);