426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
// Exercises cloud admin daemons agent router.test automation behavior with deterministic script fixtures.
|
|
|
|
import { describe, expect, it } from "bun:test";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { PassThrough } from "node:stream";
|
|
import {
|
|
buildUnresolvedAgentResponse,
|
|
extractAgentIdFromHost,
|
|
handleRequest,
|
|
isBridgeHostFallbackEnabled,
|
|
resolveSandboxRouting,
|
|
selectAgentProxyTarget,
|
|
sendResponse,
|
|
} from "./agent-router";
|
|
|
|
function makeResponseStub() {
|
|
const output = new PassThrough() as PassThrough & {
|
|
flushHeaders: () => void;
|
|
headersSent: boolean;
|
|
setHeader: (name: string, value: string) => void;
|
|
statusCode: number;
|
|
};
|
|
let headersFlushed = false;
|
|
const headers = new Map<string, string>();
|
|
output.flushHeaders = () => {
|
|
headersFlushed = true;
|
|
};
|
|
Object.defineProperty(output, "headersSent", {
|
|
get: () => headersFlushed,
|
|
});
|
|
output.setHeader = (name, value) => {
|
|
headers.set(name, value);
|
|
};
|
|
output.statusCode = 0;
|
|
return { output, headers, headersFlushed: () => headersFlushed };
|
|
}
|
|
|
|
describe("sendResponse", () => {
|
|
it("relays streaming response chunks before the upstream body closes", async () => {
|
|
const encoder = new TextEncoder();
|
|
let releaseSecondChunk: (() => void) | undefined;
|
|
const upstream = new Response(
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode("first\n\n"));
|
|
releaseSecondChunk = () => {
|
|
controller.enqueue(encoder.encode("second\n\n"));
|
|
controller.close();
|
|
};
|
|
},
|
|
}),
|
|
{ headers: { "content-type": "text/event-stream" } },
|
|
);
|
|
const { output, headers, headersFlushed } = makeResponseStub();
|
|
|
|
const firstChunk = new Promise<string>((resolve) => {
|
|
output.once("data", (chunk: Buffer) => resolve(chunk.toString()));
|
|
});
|
|
const relay = sendResponse(output as unknown as ServerResponse, upstream);
|
|
|
|
expect(await firstChunk).toBe("first\n\n");
|
|
expect(headersFlushed()).toBe(true);
|
|
expect(headers.get("content-type")).toBe("text/event-stream");
|
|
expect(output.statusCode).toBe(200);
|
|
|
|
releaseSecondChunk?.();
|
|
await relay;
|
|
});
|
|
|
|
it("ends a bodyless response without forcing streaming headers", async () => {
|
|
const { output, headersFlushed } = makeResponseStub();
|
|
|
|
await sendResponse(
|
|
output as unknown as ServerResponse,
|
|
new Response(null, { status: 204 }),
|
|
);
|
|
|
|
expect(output.statusCode).toBe(204);
|
|
expect(headersFlushed()).toBe(false);
|
|
expect(output.writableEnded).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("handleRequest routing lookup", () => {
|
|
it("rejects malformed agent ids before consulting routing state", async () => {
|
|
const response = await handleRequest(
|
|
new URL("http://localhost/agents/not-an-agent!/routing"),
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await response.json()).toEqual({ error: "invalid agent id" });
|
|
});
|
|
});
|
|
|
|
describe("resolveSandboxRouting", () => {
|
|
it("routes over the tailnet to the container port encoded in bridge_url", () => {
|
|
// After provisioning, bridge_url encodes the agent's tailnet IP + the
|
|
// container-internal port (the app binds 0.0.0.0:<containerPort>). Over the
|
|
// mesh the container is reached directly there, so bridge and web UI share
|
|
// that one port.
|
|
expect(
|
|
resolveSandboxRouting({
|
|
status: "running",
|
|
bridge_url: "http://100.64.0.21:3000",
|
|
headscale_ip: "100.64.0.21",
|
|
web_ui_port: 20001,
|
|
}),
|
|
).toEqual({
|
|
headscaleIp: "100.64.0.21",
|
|
bridgePort: 3000,
|
|
webUiPort: 3000,
|
|
bridgeTarget: "100.64.0.21:3000",
|
|
webTarget: "100.64.0.21:3000",
|
|
target: "100.64.0.21:3000",
|
|
});
|
|
});
|
|
|
|
it("ignores the host bridge_port over the tailnet (container port from bridge_url wins)", () => {
|
|
// bridge_port / web_ui_port are HOST-published ports (docker -p) that do
|
|
// not exist inside the container's netns; routing them over the tailnet
|
|
// would always connection-refuse. The container port from bridge_url wins.
|
|
expect(
|
|
resolveSandboxRouting({
|
|
status: "running",
|
|
bridge_url: "http://100.64.0.21:3000",
|
|
bridge_port: 18888,
|
|
headscale_ip: "100.64.0.21",
|
|
web_ui_port: 20001,
|
|
}),
|
|
).toMatchObject({
|
|
bridgePort: 3000,
|
|
bridgeTarget: "100.64.0.21:3000",
|
|
webTarget: "100.64.0.21:3000",
|
|
});
|
|
});
|
|
|
|
it("does not route running sandboxes without a persisted headscale IP by default", () => {
|
|
expect(
|
|
resolveSandboxRouting({
|
|
status: "running",
|
|
bridge_url: "http://172.18.0.10:18791",
|
|
headscale_ip: null,
|
|
web_ui_port: 20001,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("can opt into bridge URL host fallback for legacy sandboxes", () => {
|
|
expect(
|
|
resolveSandboxRouting(
|
|
{
|
|
status: "running",
|
|
bridge_url: "http://172.18.0.10:18791",
|
|
headscale_ip: null,
|
|
web_ui_port: 20001,
|
|
},
|
|
{ allowBridgeHostFallback: true },
|
|
),
|
|
).toMatchObject({
|
|
headscaleIp: "172.18.0.10",
|
|
bridgeTarget: "172.18.0.10:18791",
|
|
webTarget: "172.18.0.10:20001",
|
|
target: "172.18.0.10:20001",
|
|
});
|
|
});
|
|
|
|
it("refuses to route a headscale sandbox when bridge_url has no usable port", () => {
|
|
// Over the tailnet there is no safe fallback — the host ports are
|
|
// unreachable, so without the container port we must not route at all.
|
|
expect(
|
|
resolveSandboxRouting({
|
|
status: "running",
|
|
bridge_url: "not a url",
|
|
headscale_ip: "100.64.0.21",
|
|
web_ui_port: 20001,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("only enables bridge-host fallback through the explicit env flag", () => {
|
|
expect(isBridgeHostFallbackEnabled({} as NodeJS.ProcessEnv)).toBe(false);
|
|
expect(
|
|
isBridgeHostFallbackEnabled({
|
|
AGENT_ROUTER_ALLOW_BRIDGE_HOST_FALLBACK: "false",
|
|
} as NodeJS.ProcessEnv),
|
|
).toBe(false);
|
|
expect(
|
|
isBridgeHostFallbackEnabled({
|
|
AGENT_ROUTER_ALLOW_BRIDGE_HOST_FALLBACK: "true",
|
|
} as NodeJS.ProcessEnv),
|
|
).toBe(true);
|
|
expect(
|
|
isBridgeHostFallbackEnabled({
|
|
AGENT_ROUTER_ALLOW_BRIDGE_HOST_FALLBACK: "1",
|
|
} as NodeJS.ProcessEnv),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("selectAgentProxyTarget", () => {
|
|
const routing = {
|
|
bridgeTarget: "100.64.0.21:18791",
|
|
webTarget: "100.64.0.21:20001",
|
|
};
|
|
|
|
it("routes web UI paths to the web UI port", () => {
|
|
expect(selectAgentProxyTarget(routing, "/")).toBe(routing.webTarget);
|
|
expect(selectAgentProxyTarget(routing, "/health")).toBe(routing.webTarget);
|
|
expect(selectAgentProxyTarget(routing, "/assets/app.js")).toBe(
|
|
routing.webTarget,
|
|
);
|
|
});
|
|
|
|
it("routes runtime API paths to the bridge port", () => {
|
|
expect(selectAgentProxyTarget(routing, "/bridge")).toBe(
|
|
routing.bridgeTarget,
|
|
);
|
|
expect(selectAgentProxyTarget(routing, "/api/agents")).toBe(
|
|
routing.bridgeTarget,
|
|
);
|
|
expect(
|
|
selectAgentProxyTarget(routing, "/api/conversations/default/messages"),
|
|
).toBe(routing.bridgeTarget);
|
|
expect(selectAgentProxyTarget(routing, "/api/messaging/sessions")).toBe(
|
|
routing.bridgeTarget,
|
|
);
|
|
expect(selectAgentProxyTarget(routing, "/v1/chat/completions")).toBe(
|
|
routing.bridgeTarget,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildUnresolvedAgentResponse — CORS-bearing failure (#15347)", () => {
|
|
const ORIGIN = "https://app-staging.elizacloud.ai";
|
|
|
|
it("running row with no routable ingress → 503 agent_unroutable + reflected CORS + retry-after", async () => {
|
|
// A `running` sandbox whose headscale_ip never persisted is the exact 48/48
|
|
// staging state: reachable status, no mesh IP → resolveSandboxRouting = null.
|
|
const res = buildUnresolvedAgentResponse(
|
|
{ status: "running", headscale_ip: null, web_ui_port: 20001 },
|
|
ORIGIN,
|
|
);
|
|
expect(res.status).toBe(503);
|
|
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
expect(res.headers.get("vary")).toBe("origin");
|
|
expect(res.headers.get("access-control-allow-credentials")).toBe("true");
|
|
expect(res.headers.get("retry-after")).toBe("5");
|
|
const body = (await res.json()) as { code?: string; error?: string };
|
|
expect(body.code).toBe("agent_unroutable");
|
|
});
|
|
|
|
it("no such agent (undefined) → 404 not-found, still CORS-bearing", async () => {
|
|
const res = buildUnresolvedAgentResponse(undefined, ORIGIN);
|
|
expect(res.status).toBe(404);
|
|
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
expect(res.headers.get("retry-after")).toBeNull();
|
|
const body = (await res.json()) as { error?: string; code?: string };
|
|
expect(body.error).toBe("agent not found or not running");
|
|
expect(body.code).toBeUndefined();
|
|
});
|
|
|
|
it("non-running row (pending/stopped) with empty ip → 404, NOT 503 (only running is 'unroutable')", () => {
|
|
for (const status of ["pending", "stopped", "disconnected"]) {
|
|
const res = buildUnresolvedAgentResponse(
|
|
{ status, headscale_ip: "", web_ui_port: 20001 },
|
|
ORIGIN,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
}
|
|
});
|
|
|
|
it("header-less (non-browser) caller → wildcard origin", () => {
|
|
const res = buildUnresolvedAgentResponse(
|
|
{ status: "running", headscale_ip: null, web_ui_port: 20001 },
|
|
undefined,
|
|
);
|
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
});
|
|
});
|
|
|
|
describe("handleRequest — agent-host CORS preflight (#15347)", () => {
|
|
const AGENT = "e06bb509-6c52-4c33-a9f7-66addc43e8c8";
|
|
const HOST = `${AGENT}.elizacloud.ai`;
|
|
const ORIGIN = "https://app-staging.elizacloud.ai";
|
|
|
|
function fakeReq(
|
|
method: string,
|
|
host: string,
|
|
origin?: string,
|
|
): IncomingMessage {
|
|
return {
|
|
method,
|
|
headers: origin ? { host, origin } : { host },
|
|
socket: { remoteAddress: "127.0.0.1" },
|
|
} as unknown as IncomingMessage;
|
|
}
|
|
|
|
it("OPTIONS to an agent subdomain → 204 + reflected CORS, no proxy/DB hop", async () => {
|
|
// The preflight is answered at the router before any sandbox lookup, so a
|
|
// cross-origin agent call is allowed even while the agent itself is
|
|
// unroutable. A DB hit here would throw (no DATABASE_URL in unit env), so a
|
|
// clean 204 also proves the short-circuit ran before proxyAgentRequest.
|
|
const url = new URL(`http://${HOST}/api/agents`);
|
|
const res = await handleRequest(url, fakeReq("OPTIONS", HOST, ORIGIN));
|
|
expect(res.status).toBe(204);
|
|
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
});
|
|
|
|
it("non-agent host with no route match → plain 404 (unchanged)", async () => {
|
|
const res = await handleRequest(
|
|
new URL("http://cp-internal.example/nope"),
|
|
fakeReq("GET", "cp-internal.example"),
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("extractAgentIdFromHost", () => {
|
|
const agentId = "e06bb509-6c52-4c33-a9f7-66addc43e8c8";
|
|
|
|
it("extracts generated agent subdomains for the configured base domain", () => {
|
|
expect(
|
|
extractAgentIdFromHost(`${agentId}.elizacloud.ai`, "elizacloud.ai"),
|
|
).toBe(agentId);
|
|
expect(
|
|
extractAgentIdFromHost(`${agentId}.elizacloud.ai:443`, "elizacloud.ai"),
|
|
).toBe(agentId);
|
|
expect(
|
|
extractAgentIdFromHost(
|
|
`${agentId}.staging.elizacloud.ai`,
|
|
"staging.elizacloud.ai",
|
|
),
|
|
).toBe(agentId);
|
|
});
|
|
|
|
it("rejects root, unrelated, and malformed hosts", () => {
|
|
expect(extractAgentIdFromHost("elizacloud.ai", "elizacloud.ai")).toBeNull();
|
|
expect(extractAgentIdFromHost("example.com", "elizacloud.ai")).toBeNull();
|
|
expect(
|
|
extractAgentIdFromHost("not-an-agent.elizacloud.ai", "elizacloud.ai"),
|
|
).toBeNull();
|
|
});
|
|
});
|