Skip to content
Merged
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
4 changes: 4 additions & 0 deletions router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Route precedence:
- `GET`/`HEAD /connect/<id>` (Relay Connect invite links) is served by the Cloud app's `/cloud/connect/<id>` route.
- `/observer/file*` routes to the RelayFile observer app, expected to be built with `basePath: "/observer/file"`.
- `/observer*` routes to the Relaycast observer app.
- On `arelay.to`, `GET`/`HEAD /register` and
`/api/v1/(registrations|agents)/**` route to the `relay-agent` Worker;
`POST /<handle>/<32hex>` routes there on both `arelay.to` and
`www.arelay.to`. The Worker owns registry lookup and its 404/410 decisions.
- everything else falls back to the legacy proxy target.

RelayFile observer deployment is wired from the production job in
Expand Down
81 changes: 68 additions & 13 deletions router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,19 @@ const CONNECT_INVITE_PATH = /^(\/connect\/[A-Za-z0-9_-]{32,64}(?:\.(?:json|md))?
// site, but its bare root and the signed-in cloud app send people to agentrelay.com.
const SHORT_HOST = "arelay.to";
const SHORT_HOST_WWW = "www.arelay.to";
// Agents a visitor's coding agent can chat with from a pasted snippet. The
// conversation endpoint is /<agent>/<32 hex id> on either host.
const AGENT_CHAT_AGENTS = new Set(["agent-relay"]);
const AGENT_CHAT_PATH = /^\/([a-z0-9-]+)\/([0-9a-f]{32})\/?$/;
const HANDLE_SEGMENT = "[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])";
// Keep this aligned with relay-agent's registry reservation set. agent-relay is
// the one seeded exception; every other reserved site/API slug must retain its
// router meaning instead of being rewritten as a company profile.
const RESERVED_AGENT_PAGE_HANDLES = new Set([
"register", "connect", "api", "cloud", "admin", "www", "help", "support",
"docs", "status", "security", "abuse", "login", "signup",
]);
// A visitor's coding agent chats through /<handle>/<32 hex id>. arelay.to
// accepts every handle-shaped slug and lets relay-agent's registry decide
// whether it exists. agentrelay.com retains its established agent-relay route.
const AGENT_CHAT_PATH = new RegExp(`^/(${HANDLE_SEGMENT})/([0-9a-f]{32})/?$`);
const REGISTRY_API_PATH = /^\/api\/v1\/(?:registrations|agents)(?:\/|$)/;
const WEBHOOK_ORIGIN_FLAG_KEY = "WEBHOOK_ORIGIN";
export const WILL_CALENDAR_URL = "https://calendar.app.google/RqLuQyT3dYe5e2YdA";
export const KHALIQ_CALENDAR_URL = "https://calendly.com/khaliq-agent-relay/30min";
Expand Down Expand Up @@ -135,7 +144,7 @@ export function getAgentChatCloudPath(
}

const match = AGENT_CHAT_PATH.exec(pathname);
if (!match || !AGENT_CHAT_AGENTS.has(match[1])) {
if (!match || (hostname === PRIMARY_HOST && match[1] !== "agent-relay")) {
Comment thread
khaliqgant marked this conversation as resolved.
Comment thread
khaliqgant marked this conversation as resolved.
return undefined;
}
return `${CLOUD_PATH_PREFIX}/api/v1/agent-chat/${match[1]}/${match[2]}`;
Expand All @@ -158,11 +167,11 @@ export function getAgentPagePath(
}
let agent: string | undefined;
if (hostname === SHORT_HOST || hostname === SHORT_HOST_WWW) {
agent = /^\/([a-z0-9-]+)\/?$/.exec(pathname)?.[1];
agent = new RegExp(`^/(${HANDLE_SEGMENT})/?$`).exec(pathname)?.[1];
} else if (hostname === PRIMARY_HOST) {
agent = /^\/u\/([a-z0-9-]+)\/?$/.exec(pathname)?.[1];
agent = new RegExp(`^/u/(${HANDLE_SEGMENT})/?$`).exec(pathname)?.[1];
}
if (!agent || !AGENT_CHAT_AGENTS.has(agent)) {
if (!agent || RESERVED_AGENT_PAGE_HANDLES.has(agent)) {
return undefined;
}
const wantsHtml = method === "HEAD" || prefersHtmlOverMarkdown(accept);
Expand Down Expand Up @@ -216,11 +225,27 @@ export function getLegacyAgentPageRedirect(url: URL): string | undefined {
return undefined;
}
const agent = /^\/([a-z0-9-]+)\/?$/.exec(url.pathname)?.[1];
return agent && AGENT_CHAT_AGENTS.has(agent)
return agent === "agent-relay"
? `https://${PRIMARY_HOST}/u/${agent}${url.search}`
: undefined;
}

// Registration and management are served directly by relay-agent. The public
// API matcher is prefix-bounded so lookalikes such as /registrations-legacy do
// not escape the marketing site.
export function isRelayAgentRegistryRoute(
hostname: string,
pathname: string,
method: string,
): boolean {
if (hostname !== SHORT_HOST) return false;
if ((pathname === "/register" || pathname === "/register/")
&& (method === "GET" || method === "HEAD")) {
return true;
}
return REGISTRY_API_PATH.test(pathname);
}

// arelay.to only fronts agent chat: its root, its www alias and the signed-in
// cloud app redirect to the primary host.
export function getShortHostRedirect(url: URL): string | undefined {
Expand Down Expand Up @@ -554,7 +579,16 @@ export default {
request.method,
request.headers.get("accept"),
);
const shortHostRedirect = agentChatCloudPath || agentPagePath
const registryRoute = isRelayAgentRegistryRoute(
url.hostname,
url.pathname,
request.method,
);
const registryOwnedChatRoute = Boolean(
agentChatCloudPath && AGENT_CHAT_PATH.exec(url.pathname)?.[1] !== "agent-relay",
);
const relayAgentRoute = Boolean(agentChatCloudPath || registryRoute);
const shortHostRedirect = relayAgentRoute || agentPagePath
Comment thread
khaliqgant marked this conversation as resolved.
? undefined
: getShortHostRedirect(url);
if (shortHostRedirect) {
Expand Down Expand Up @@ -592,13 +626,13 @@ export default {
// content, so neither the new route nor the existing Cloud fallback belongs
// in the replay corpus. Agent pages and guides mint a conversation URL in
// every response, so they stay out too, however they were reached.
const recorderRequestClone = recorderEnv && !agentChatCloudPath && !isAgentPageRequestPath(url.pathname)
Comment thread
cursor[bot] marked this conversation as resolved.
const recorderRequestClone = recorderEnv && !relayAgentRoute && !isAgentPageRequestPath(url.pathname)
? (request.clone() as unknown as Request)
: null;

// Production config includes the service binding. Removing it (and any
// origin alternative) is the rollback flag that restores the Cloud route.
if (agentChatCloudPath && relayAgentEnabled(env)) {
if (relayAgentRoute && relayAgentEnabled(env)) {
Comment thread
khaliqgant marked this conversation as resolved.
try {
const workerResponse = await fetchRelayAgent(request, url, env);
return workerResponse;
Expand All @@ -609,7 +643,10 @@ export default {
: "relay_agent_upstream_failed",
message: error instanceof Error ? error.message : "unknown error",
}));
return new Response("The agent chat is unavailable. Retry the same command shortly.\n", {
const unavailableMessage = registryRoute
? "The agent registry is unavailable. Retry shortly.\n"
: "The agent chat is unavailable. Retry the same command shortly.\n";
return new Response(unavailableMessage, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
Expand All @@ -621,6 +658,24 @@ export default {
}
}

// Unlike the legacy agent-relay chat route, registry endpoints have no
// Cloud fallback. Keep them off the marketing origin when the dedicated
// Worker is absent during a rollback or unavailable environment.
if (registryRoute || registryOwnedChatRoute) {
const unavailableMessage = registryRoute
? "The agent registry is unavailable. Retry shortly.\n"
: "The agent chat is unavailable. Retry the same command shortly.\n";
return new Response(unavailableMessage, {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
"referrer-policy": "no-referrer",
"x-content-type-options": "nosniff",
},
});
}

const connectInviteCloudPath = getConnectInviteCloudPath(
url.hostname,
url.pathname,
Expand Down
105 changes: 100 additions & 5 deletions router/test/agent-chat.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import worker, { getAgentChatCloudPath, getAgentPagePath, getLegacyAgentPageRedirect, getShortHostRedirect, prefersHtmlOverMarkdown } from "../index.js";
import worker, {
getAgentChatCloudPath,
getAgentPagePath,
getLegacyAgentPageRedirect,
getShortHostRedirect,
isRelayAgentRegistryRoute,
prefersHtmlOverMarkdown,
} from "../index.js";

const ID = "0123456789abcdef0123456789abcdef";

Expand Down Expand Up @@ -28,6 +35,15 @@ describe("router agent chat", () => {
}
});

it("accepts any handle-shaped chat slug on arelay.to but keeps agentrelay.com scoped", () => {
expect(getAgentChatCloudPath("arelay.to", `/acme-support/${ID}`, "POST"))
.toBe(`/cloud/api/v1/agent-chat/acme-support/${ID}`);
expect(getAgentChatCloudPath("www.arelay.to", `/acme-support/${ID}`, "POST"))
.toBe(`/cloud/api/v1/agent-chat/acme-support/${ID}`);
expect(getAgentChatCloudPath("agentrelay.com", `/acme-support/${ID}`, "POST"))
.toBeUndefined();
});

it("only claims POSTs", () => {
for (const method of ["GET", "HEAD", "OPTIONS", "DELETE"]) {
expect(getAgentChatCloudPath("arelay.to", `/agent-relay/${ID}`, method)).toBeUndefined();
Expand All @@ -45,14 +61,70 @@ describe("router agent chat", () => {
expect(new URL(cloud.fetch.mock.calls[0][0].url).pathname).toBe(`/cloud/api/v1/agent-chat/agent-relay/${ID}`);
});

it("leaves the page, the bridge script, unknown agents and other hosts alone", () => {
it("leaves the page, bridge script, malformed handles and other hosts alone", () => {
expect(getAgentChatCloudPath("arelay.to", "/agent-relay", "POST")).toBeUndefined();
expect(getAgentChatCloudPath("arelay.to", "/agent-relay/bridge.sh", "POST")).toBeUndefined();
expect(getAgentChatCloudPath("arelay.to", `/someone-else/${ID}`, "POST")).toBeUndefined();
expect(getAgentChatCloudPath("arelay.to", `/Not-A-Handle/${ID}`, "POST")).toBeUndefined();
expect(getAgentChatCloudPath("arelay.to", `/agent-relay/${ID.toUpperCase()}`, "POST")).toBeUndefined();
expect(getAgentChatCloudPath("example.com", `/agent-relay/${ID}`, "POST")).toBeUndefined();
});

it("forwards the guide and bounded registry API paths only on arelay.to", async () => {
expect(isRelayAgentRegistryRoute("arelay.to", "/register", "GET")).toBe(true);
expect(isRelayAgentRegistryRoute("arelay.to", "/register", "HEAD")).toBe(true);
expect(isRelayAgentRegistryRoute("arelay.to", "/register/", "GET")).toBe(true);
expect(isRelayAgentRegistryRoute("arelay.to", "/register", "POST")).toBe(false);
expect(isRelayAgentRegistryRoute("arelay.to", "/api/v1/registrations", "POST")).toBe(true);
expect(isRelayAgentRegistryRoute("arelay.to", "/api/v1/registrations/id/verify", "POST")).toBe(true);
expect(isRelayAgentRegistryRoute("arelay.to", "/api/v1/agents/acme/manage", "PATCH")).toBe(true);
expect(isRelayAgentRegistryRoute("arelay.to", "/api/v1/registrations-legacy", "GET")).toBe(false);
expect(isRelayAgentRegistryRoute("www.arelay.to", "/register", "GET")).toBe(false);
expect(isRelayAgentRegistryRoute("agentrelay.com", "/api/v1/agents/acme", "GET")).toBe(false);

const cloud = { fetch: vi.fn(async () => new Response("wrong upstream")) };
const relayAgent = {
fetch: vi.fn(async (_request: Request) => Response.json({ handle: "acme" })),
};
const response = await worker.fetch(
new Request("https://arelay.to/api/v1/agents/acme?format=json"),
buildEnv(cloud, { RELAY_AGENT_WORKER: relayAgent }),
ctx,
);
expect(response.status).toBe(200);
expect(cloud.fetch).not.toHaveBeenCalled();
const forwarded = relayAgent.fetch.mock.calls[0][0];
expect(forwarded.url).toBe("https://arelay.to/api/v1/agents/acme?format=json");

const register = await worker.fetch(
new Request("https://arelay.to/register", { headers: { accept: "text/html" } }),
buildEnv(cloud, { RELAY_AGENT_WORKER: relayAgent }),
ctx,
);
expect(register.status).toBe(200);
expect(relayAgent.fetch.mock.calls[1][0].url).toBe("https://arelay.to/register");
});

it("fails registry routes closed when relay-agent is not configured", async () => {
const cloud = { fetch: vi.fn(async () => new Response("wrong upstream")) };
const registryResponse = await worker.fetch(
new Request("https://arelay.to/register"),
buildEnv(cloud),
ctx,
);
const companyChatResponse = await worker.fetch(
new Request(`https://arelay.to/acme-support/${ID}`, { method: "POST", body: "hello" }),
buildEnv(cloud),
ctx,
);
expect(registryResponse.status).toBe(503);
expect(registryResponse.headers.get("cache-control")).toBe("no-store");
expect(await registryResponse.text()).toContain("agent registry is unavailable");
expect(companyChatResponse.status).toBe(503);
expect(companyChatResponse.headers.get("cache-control")).toBe("no-store");
expect(await companyChatResponse.text()).toContain("agent chat is unavailable");
expect(cloud.fetch).not.toHaveBeenCalled();
});

it("forwards a conversation POST, body intact, to the cloud worker", async () => {
const cloud = { fetch: vi.fn(async (_request: Request) => new Response("agent-relay: hi", { status: 200 })) };
const response = await worker.fetch(
Expand Down Expand Up @@ -162,8 +234,24 @@ describe("router agent chat", () => {
expect(logged).toHaveBeenCalledWith(expect.stringContaining("relay_agent_upstream_failed"));
});

it("uses registry-specific wording when the registry upstream fails", async () => {
const cloud = { fetch: vi.fn(async () => new Response("wrong upstream")) };
const relayAgent = { fetch: vi.fn(async () => { throw new Error("down"); }) };
vi.spyOn(console, "error").mockImplementation(() => undefined);
const response = await worker.fetch(
new Request("https://arelay.to/api/v1/agents/acme"),
buildEnv(cloud, { RELAY_AGENT_WORKER: relayAgent }),
ctx,
);

expect(response.status).toBe(503);
expect(await response.text()).toContain("agent registry is unavailable");
expect(cloud.fetch).not.toHaveBeenCalled();
});

it("sends the short host's root, www and cloud app to agentrelay.com", () => {
expect(getShortHostRedirect(new URL("https://arelay.to/"))).toBe("https://agentrelay.com/");
expect(getShortHostRedirect(new URL("https://arelay.to/cloud"))).toBe("https://agentrelay.com/cloud");
expect(getShortHostRedirect(new URL("https://arelay.to/cloud/teams?x=1"))).toBe("https://agentrelay.com/cloud/teams?x=1");
expect(getShortHostRedirect(new URL("https://www.arelay.to/agent-relay"))).toBe("https://arelay.to/agent-relay");
expect(getShortHostRedirect(new URL("https://arelay.to/agent-relay"))).toBeUndefined();
Expand All @@ -174,9 +262,12 @@ describe("router agent chat", () => {
const browser = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
expect(getAgentPagePath("arelay.to", "/agent-relay", "GET", browser)).toBe("/u/agent-relay");
expect(getAgentPagePath("agentrelay.com", "/u/agent-relay", "GET", browser)).toBe("/u/agent-relay");
expect(getAgentPagePath("arelay.to", "/acme-support", "GET", browser)).toBe("/u/acme-support");
expect(getAgentPagePath("agentrelay.com", "/u/acme-support", "GET", browser)).toBe("/u/acme-support");
for (const accept of ["*/*", null, "text/markdown", "text/markdown, text/html;q=0.9, */*;q=0.8", "text/plain"]) {
expect(getAgentPagePath("arelay.to", "/agent-relay", "GET", accept)).toBe("/u/agent-relay/agent.md");
expect(getAgentPagePath("agentrelay.com", "/u/agent-relay/", "GET", accept)).toBe("/u/agent-relay/agent.md");
expect(getAgentPagePath("arelay.to", "/acme-support", "GET", accept)).toBe("/u/acme-support/agent.md");
}
});

Expand Down Expand Up @@ -225,9 +316,13 @@ describe("router agent chat", () => {
expect(put).not.toHaveBeenCalled();
});

it("leaves other paths, unknown agents, POSTs and other hosts alone", () => {
it("leaves other paths, invalid handles, POSTs and other hosts alone", () => {
expect(getAgentPagePath("arelay.to", "/agent-relay/bridge.sh", "GET", "*/*")).toBeUndefined();
expect(getAgentPagePath("arelay.to", "/someone-else", "GET", "*/*")).toBeUndefined();
expect(getAgentPagePath("arelay.to", "/-invalid", "GET", "*/*")).toBeUndefined();
expect(getAgentPagePath("arelay.to", "/x", "GET", "*/*")).toBeUndefined();
for (const reserved of ["register", "connect", "api", "cloud", "admin", "www", "help", "support", "docs", "status", "security", "abuse", "login", "signup"]) {
expect(getAgentPagePath("arelay.to", `/${reserved}`, "GET", "*/*")).toBeUndefined();
}
expect(getAgentPagePath("arelay.to", "/agent-relay", "POST", "*/*")).toBeUndefined();
expect(getAgentPagePath("agentrelay.com", "/agent-relay", "GET", "*/*")).toBeUndefined();
expect(getAgentPagePath("example.com", "/u/agent-relay", "GET", "*/*")).toBeUndefined();
Expand Down
Loading