Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions server/src/agents/connection-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export async function testAgentConnection(
headers?: Record<string, string>;
allowPrivateHosts?: boolean;
allowedHosts?: ReadonlySet<string>;
/** The deployment's own agent addresses, reachable at exactly those URLs; see checkAgentEndpoint. */
managedEndpoints?: readonly (string | URL)[];
fetchImpl?: typeof fetch;
timeoutMs?: number;
} = {},
Expand All @@ -149,13 +151,19 @@ 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 };

// Wrapped rather than called directly, so the address the request finally lands on is checked too.
// 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 }
: {}),
Expand Down
35 changes: 35 additions & 0 deletions server/src/agents/endpoint.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -71,13 +72,39 @@ export function checkAgentEndpoint(
options: {
allowPrivateHosts?: boolean;
allowedHosts?: ReadonlySet<string>;
/**
* 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()) {
return { allowed: false, reason: "An agent needs a web address." };
}

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.
Expand Down Expand Up @@ -258,6 +285,11 @@ export function createAgentFetch(
* not be a way to arrive somewhere registration would have declined.
*/
allowedHosts?: ReadonlySet<string>;
/**
* 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.
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions server/src/agents/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export function parseAgentInput(
allowPrivateHosts = false,
/** Private addresses this deployment named as acceptable. Empty is the default posture. */
allowedHosts: ReadonlySet<string> = 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." };
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
132 changes: 123 additions & 9 deletions server/tests/agent-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down