Files
elizaos--eliza/packages/cloud/api/__tests__/dedicated-agent-proxy.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

440 lines
18 KiB
TypeScript

// Exercises cloud API tests dedicated agent proxy.test behavior with deterministic Worker route fixtures.
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from "bun:test";
/**
* Security tests for the dedicated-agent unified-auth proxy. The single
* invariant under test: the agent's `ELIZA_API_TOKEN` is injected ONLY for a
* validated owner of a RUNNING dedicated agent; every other path proxies
* UNCHANGED (so the container's own auth stays the backstop).
*/
let authResult: { user: { id: string; organization_id: string } } | "throw" =
"throw";
let sandboxResult: Record<string, unknown> | null = null;
let creditGateResult: { allowed: boolean; balance: number; error?: string } = {
allowed: true,
balance: 100,
};
let enqueueCalls = 0;
mock.module("@/lib/runtime/cloud-bindings", () => ({
runWithCloudBindingsAsync: (_b: unknown, fn: () => Promise<unknown>) => fn(),
}));
mock.module("@/lib/auth", () => ({
requireAuthOrApiKeyWithOrg: async () => {
if (authResult === "throw") throw new Error("unauthorized");
return authResult;
},
}));
mock.module("@/db/repositories/agent-sandboxes", () => ({
agentSandboxesRepository: { findByIdAndOrg: async () => sandboxResult },
}));
mock.module("@/lib/services/provisioning-jobs", () => ({
provisioningJobService: {
enqueueAgentProvisionOnce: async () => {
enqueueCalls++;
return {
job: { id: "job-1" },
created: true,
};
},
},
}));
mock.module("@/lib/services/provisioning-worker-health", () => ({
checkProvisioningWorkerHealth: async () => ({ ok: true }),
}));
mock.module("@/lib/services/agent-billing-gate", () => ({
checkAgentCreditGate: async () => creditGateResult,
}));
mock.module("@/lib/utils/logger", () => ({
logger: { warn() {}, error() {}, info() {}, debug() {} },
}));
let captured: Request | null = null;
// Per-test override for the origin fetch; null = the default instant-200 stub.
let fetchImpl: ((request: Request) => Promise<Response>) | null = null;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const request = input instanceof Request ? input : new Request(input);
captured = request;
if (fetchImpl) return fetchImpl(request);
return new Response("ok", { status: 200 });
}) as typeof fetch;
afterAll(() => {
globalThis.fetch = originalFetch;
});
const { handleDedicatedAgentProxy, __dedicatedProxyTestHooks } = await import(
"../src/dedicated-agent-proxy"
);
const AGENT = "11111111-1111-1111-1111-111111111111";
const ENV = { AGENT_ROUTER_ORIGIN_HOST: "cp.example.test" } as never;
function makeRequest(cloudToken?: string, origin?: string): Request {
const headers = new Headers();
if (cloudToken) headers.set("authorization", `Bearer ${cloudToken}`);
if (origin) headers.set("origin", origin);
return new Request(`https://${AGENT}.elizacloud.ai/api/status`, { headers });
}
const urlOf = (r: Request) => new URL(r.url);
// A running row carries a mesh IP once it has joined headscale; without it the
// proxy short-circuits (running-but-unroutable, #15347), so the happy-path
// fixture pins one to prove the token-swap path still routes.
const runningDedicated = {
id: AGENT,
execution_tier: "dedicated-always",
status: "running",
headscale_ip: "100.64.0.21",
environment_vars: { ELIZA_API_TOKEN: "agent-secret-token" },
agent_name: "qa",
updated_at: new Date(),
};
beforeEach(() => {
captured = null;
fetchImpl = null;
authResult = "throw";
sandboxResult = null;
creditGateResult = { allowed: true, balance: 100 };
enqueueCalls = 0;
});
describe("dedicated-agent-proxy — unified auth", () => {
test("validated OWNER of a RUNNING agent → injects the agent token, strips the cloud token, targets the CP", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = runningDedicated;
const r = makeRequest(
"cloud-token-abc",
"https://app-staging.elizacloud.ai",
);
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(200);
expect(captured).not.toBeNull();
// The container gets the agent's own token, NOT the cloud token.
expect(captured?.headers.get("authorization")).toBe(
"Bearer agent-secret-token",
);
expect(captured?.headers.get("x-api-key")).toBeNull();
expect(new URL(captured?.url ?? "").hostname).toBe("cp.example.test");
// withCors backfills the browser Origin even though the mocked upstream
// ("ok") carried none, so the proxied response is never CORS-opaque (#15347).
expect(res.headers.get("access-control-allow-origin")).toBe(
"https://app-staging.elizacloud.ai",
);
});
test("NO cloud token → pass through unchanged (never injects the agent token)", async () => {
authResult = "throw";
const r = makeRequest();
await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(captured?.headers.get("authorization")).toBeNull();
});
test("authenticated NON-OWNER (findByIdAndOrg → null) → pass through, agent token NEVER injected", async () => {
authResult = { user: { id: "att", organization_id: "attacker-org" } };
sandboxResult = null; // attacker's org does not own this agent
const r = makeRequest("attacker-cloud-token");
await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
// forwarded verbatim — the container's own auth rejects it; the secret leaks nowhere
expect(captured?.headers.get("authorization")).toBe(
"Bearer attacker-cloud-token",
);
});
test("shared-tier agent → pass through, no injection", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = {
...runningDedicated,
execution_tier: "shared",
};
const r = makeRequest("cloud-token");
await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(captured?.headers.get("authorization")).toBe("Bearer cloud-token");
});
test("owner of a NON-RUNNING agent → 202 resume, does NOT proxy to the container", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = { ...runningDedicated, status: "stopped" };
const r = makeRequest("cloud-token", "https://app-staging.elizacloud.ai");
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(202);
expect(res.headers.get("Retry-After")).toBe("5");
expect(captured).toBeNull();
// Regression: the 202 previously bypassed CORS entirely (this handler is
// mounted before Hono's cors middleware), so the browser could not read it.
expect(res.headers.get("access-control-allow-origin")).toBe(
"https://app-staging.elizacloud.ai",
);
});
test("owner of a NON-RUNNING agent WITH sufficient credits → 202 and enqueues the resume (#11583)", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = { ...runningDedicated, status: "stopped" };
creditGateResult = { allowed: true, balance: 100 };
const r = makeRequest("cloud-token");
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(202);
expect(enqueueCalls).toBe(1); // paying org is not blocked
});
test("owner of a SUSPENDED / zero-balance agent → 402 and NO re-provision (free-compute suspension bypass closed, #11583)", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
// active-billing suspends a non-paying org's agent to `stopped`; without the
// gate, hitting the agent subdomain would re-provision it for free.
sandboxResult = { ...runningDedicated, status: "stopped" };
creditGateResult = {
allowed: false,
balance: 0,
error: "Insufficient credits.",
};
const r = makeRequest("cloud-token", "https://app-staging.elizacloud.ai");
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(402);
// Regression: browser-visible billing failure must carry CORS (#15347).
expect(res.headers.get("access-control-allow-origin")).toBe(
"https://app-staging.elizacloud.ai",
);
const body = (await res.json()) as { code?: string };
expect(body.code).toBe("insufficient_credits");
expect(enqueueCalls).toBe(0); // no free compute
expect(captured).toBeNull(); // nothing proxied to the container
});
// A browser `new WebSocket()` can't set headers, so the app passes the cloud
// token as `?token=`. The proxy must validate it the same way and rewrite it
// to the agent token (the container reads `?token=` via ELIZA_ALLOW_WS_QUERY_TOKEN).
function makeWsRequest(cloudToken?: string): Request {
const u = new URL(`https://${AGENT}.elizacloud.ai/ws`);
if (cloudToken) u.searchParams.set("token", cloudToken);
return new Request(u.toString()); // no Authorization header
}
test("WS upgrade with ?token= (owner, running) → rewrites ?token= to the agent token + sets header", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = runningDedicated;
const r = makeWsRequest("cloud-token-abc");
await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(captured).not.toBeNull();
expect(new URL(captured?.url ?? "").searchParams.get("token")).toBe(
"agent-secret-token",
);
expect(captured?.headers.get("authorization")).toBe(
"Bearer agent-secret-token",
);
});
test("WS ?token= from a NON-OWNER → pass through, ?token= NOT rewritten (agent token never leaks)", async () => {
authResult = { user: { id: "att", organization_id: "attacker-org" } };
sandboxResult = null; // attacker's org does not own this agent
const r = makeWsRequest("attacker-cloud-token");
await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(new URL(captured?.url ?? "").searchParams.get("token")).toBe(
"attacker-cloud-token",
);
});
});
/**
* The demo show-stopper (#15347): a staging agent is `running` but never joined
* headscale, so `headscale_ip` is empty and the CP returns a CORS-less 404 the
* browser reads as an opaque CORS error. The proxy must (a) answer preflights,
* (b) short-circuit the doomed CP round-trip with a readable 503, and (c)
* guarantee CORS on every browser-visible response.
*/
describe("dedicated-agent-proxy — CORS + unroutable short-circuit (#15347)", () => {
const ORIGIN = "https://app-staging.elizacloud.ai";
test("OPTIONS preflight → 204 + reflected CORS, no auth/DB/proxy work", async () => {
authResult = "throw"; // even a total auth failure must not reach here
const r = new Request(`https://${AGENT}.elizacloud.ai/api/status`, {
method: "OPTIONS",
headers: { origin: ORIGIN },
});
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
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");
expect(captured).toBeNull(); // preflight is answered at the edge
});
test("owner + running + EMPTY headscale_ip + fallback off → 503 agent_unroutable + CORS, no CP round-trip", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = { ...runningDedicated, headscale_ip: "" };
const r = makeRequest("cloud-token", ORIGIN);
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(503);
expect(res.headers.get("Retry-After")).toBe("5");
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
const body = (await res.json()) as { code?: string; success?: boolean };
expect(body.code).toBe("agent_unroutable");
expect(body.success).toBe(false);
// The whole point: never proxy a guaranteed CORS-less 404 to the CP.
expect(captured).toBeNull();
});
test("owner + running + NULL headscale_ip → 503 (null is treated as empty)", async () => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = { ...runningDedicated, headscale_ip: null };
const r = makeRequest("cloud-token", ORIGIN);
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(503);
expect(captured).toBeNull();
});
test("bridge-host fallback ON + running + empty ip → proxied, NOT short-circuited", async () => {
// The CP can reach the agent via published host ports when the operator
// opts into the fallback, so the worker must not pre-empt that with a 503.
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = { ...runningDedicated, headscale_ip: "" };
const fallbackEnv = {
AGENT_ROUTER_ORIGIN_HOST: "cp.example.test",
AGENT_ROUTER_ALLOW_BRIDGE_HOST_FALLBACK: "1",
} as never;
const r = makeRequest("cloud-token", ORIGIN);
const res = await handleDedicatedAgentProxy(
r,
fallbackEnv,
urlOf(r),
AGENT,
);
expect(res.status).toBe(200);
expect(captured).not.toBeNull(); // proxied to the CP
expect(captured?.headers.get("authorization")).toBe(
"Bearer agent-secret-token",
);
});
test("unauthenticated pass-through still gets CORS backfilled", async () => {
// No valid token → pass through to the CP unchanged; the CP has no CORS, so
// withCors must still backfill it or the browser sees an opaque failure.
authResult = "throw";
const r = makeRequest(undefined, ORIGIN);
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
expect(captured).not.toBeNull(); // forwarded to the CP
});
});
/**
* Stream-aware origin timeout. The old `AbortSignal.timeout(30s)` on the whole
* fetch killed any body still flowing at t=30s — a >30s agent chat turn (or any
* long SSE stream) surfaced to the client as an unhandled TimeoutError
* (CF error 1101 / empty body) while the agent's reply persisted server-side.
* The timeout must gate the HEADERS phase only: once a response starts, the
* body flows for as long as the origin keeps it open, and a true
* headers-timeout becomes a structured, retryable 504 the client can read.
* Timeouts are shrunk to milliseconds via the test hook.
*/
describe("dedicated-agent-proxy — stream-aware origin timeout", () => {
const ORIGIN = "https://app-staging.elizacloud.ai";
const DEFAULT_TIMEOUT_MS = __dedicatedProxyTestHooks.originHeadersTimeoutMs;
const encoder = new TextEncoder();
beforeEach(() => {
authResult = { user: { id: "u1", organization_id: "org1" } };
sandboxResult = runningDedicated;
});
afterEach(() => {
__dedicatedProxyTestHooks.setOriginHeadersTimeoutMs(DEFAULT_TIMEOUT_MS);
});
test("body still streaming PAST the headers timeout is not aborted — it completes", async () => {
__dedicatedProxyTestHooks.setOriginHeadersTimeoutMs(50);
fetchImpl = async (request) => {
// Headers arrive well inside the timeout…
await new Promise((resolve) => setTimeout(resolve, 10));
const body = new ReadableStream<Uint8Array>({
start(controller) {
// The regression detector: if the proxy leaves its abort timer armed
// after headers, the signal fires at t=50ms and errors the body
// mid-stream (the old whole-transfer AbortSignal.timeout behavior).
request.signal.addEventListener("abort", () => {
try {
controller.error(
request.signal.reason ?? new Error("aborted mid-stream"),
);
} catch {
// already closed — nothing to error
}
});
controller.enqueue(encoder.encode("first-chunk "));
// …but the body keeps flowing to 3x the headers timeout.
setTimeout(() => {
controller.enqueue(encoder.encode("late-chunk-past-timeout"));
controller.close();
}, 150);
},
});
return new Response(body, { status: 200 });
};
const r = makeRequest("cloud-token", ORIGIN);
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(200);
// Reading to completion is the assertion: the old behavior errors here.
const text = await res.text();
expect(text).toBe("first-chunk late-chunk-past-timeout");
});
test("origin exceeding the HEADERS timeout → structured 504 agent_timeout JSON, not a thrown TimeoutError", async () => {
__dedicatedProxyTestHooks.setOriginHeadersTimeoutMs(20);
// Origin never produces headers; a real fetch rejects when the signal aborts.
fetchImpl = (request) =>
new Promise<Response>((_resolve, reject) => {
request.signal.addEventListener("abort", () =>
reject(
request.signal.reason ??
new DOMException("The operation timed out.", "TimeoutError"),
),
);
});
const r = makeRequest("cloud-token", ORIGIN);
// Old behavior: this await THROWS (client saw CF 1101 / empty body).
const res = await handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT);
expect(res.status).toBe(504);
expect(res.headers.get("Retry-After")).toBe("5");
// Browser-readable: CORS is backfilled on the error envelope (#15347).
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
const body = (await res.json()) as { code?: string; success?: boolean };
expect(body.success).toBe(false);
expect(body.code).toBe("agent_timeout");
});
test("non-timeout fetch failures still propagate (fail-closed pass-through untouched)", async () => {
__dedicatedProxyTestHooks.setOriginHeadersTimeoutMs(1_000);
fetchImpl = async () => {
throw new TypeError("connection refused");
};
const r = makeRequest("cloud-token", ORIGIN);
// Not a headers timeout → the error is NOT swallowed into a 504.
await expect(
handleDedicatedAgentProxy(r, ENV, urlOf(r), AGENT),
).rejects.toThrow("connection refused");
});
});