Files
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

934 lines
31 KiB
TypeScript

/**
* Shared Cloud Agent Logic
*
* Single implementation of the cloud-agent runtime, health server, and
* bridge server. Both the main entrypoint and the template entrypoint
* import from here, passing a config that captures the small differences.
*/
import * as crypto from "node:crypto";
import * as http from "node:http";
import restartExitCodeDefinition from "../../shared/src/restart-exit-code.json" with {
type: "json",
};
const CLOUD_AGENT_RESTART_EXIT_CODE = restartExitCodeDefinition.restartExitCode;
// ─── Logger ─────────────────────────────────────────────────────────────
//
// This file is bundled with `--external:@elizaos/*` and runs in a minimal
// container before any `@elizaos/*` package is statically resolvable, so it
// cannot import the shared structured logger. This tiny shim gives the same
// `[ClassName]`-prefixed, level-tagged, single-line structured output the
// logger commandment requires (message + optional context object), without a
// hard dependency. All cloud-agent runtime logging goes through it.
const logger = {
info(message: string, context?: Record<string, unknown>): void {
if (context) console.log(`[cloud-agent] ${message}`, context);
else console.log(`[cloud-agent] ${message}`);
},
warn(message: string, context?: Record<string, unknown>): void {
if (context) console.warn(`[cloud-agent] ${message}`, context);
else console.warn(`[cloud-agent] ${message}`);
},
error(message: string, context?: Record<string, unknown>): void {
if (context) console.error(`[cloud-agent] ${message}`, context);
else console.error(`[cloud-agent] ${message}`);
},
};
/**
* `.catch` handler for an optional plugin dynamic import: keeps the degrade
* (agent boots without the plugin) but surfaces the import failure so a broken
* build is distinguishable from a plugin that is deliberately absent in this
* deploy shape.
*/
// error-policy:J4 optional plugin unavailable → degrade; failure surfaced via logger
function logPluginLoadFailure(id: string): (error: unknown) => null {
return (error) => {
logger.error(`failed to load ${id}; continuing without it`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
};
}
// ─── Types ──────────────────────────────────────────────────────────────
export interface BridgeRpcParams {
text?: string;
roomId?: string;
mode?: string;
channelType?: string;
source?: string;
sender?: {
id?: string;
username?: string;
displayName?: string;
metadata?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
}
export type NormalizedBridgeMessage = {
text: string;
roomKey: string;
mode: "simple" | "power";
channelType: "DM" | "GROUP";
source: string;
sender?: {
id?: string;
username?: string;
displayName?: string;
metadata?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
};
export type BridgeMessageResult = {
text: string;
failureKind?: string;
};
type BridgeCallbackContent = {
text?: unknown;
failureKind?: unknown;
};
export function appendBridgeCallbackContent(
result: BridgeMessageResult,
content: BridgeCallbackContent,
): BridgeMessageResult {
if (typeof content.text === "string") result.text += content.text;
if (
!result.failureKind &&
typeof content.failureKind === "string" &&
content.failureKind.trim()
) {
result.failureKind = content.failureKind.trim();
}
return result;
}
export function bridgeResultText(result: BridgeMessageResult): string {
return result.text || "(no response)";
}
export function normalizeBridgeMessage(
params?: BridgeRpcParams,
): NormalizedBridgeMessage {
const trimmedRoomId =
typeof params?.roomId === "string" && params.roomId.trim().length > 0
? params.roomId.trim()
: "default";
const source =
typeof params?.source === "string" && params.source.trim().length > 0
? params.source.trim()
: "cloud-bridge";
const sender =
params?.sender && typeof params.sender === "object"
? {
...(typeof params.sender.id === "string" && params.sender.id.trim()
? { id: params.sender.id.trim() }
: {}),
...(typeof params.sender.username === "string" &&
params.sender.username.trim()
? { username: params.sender.username.trim() }
: {}),
...(typeof params.sender.displayName === "string" &&
params.sender.displayName.trim()
? { displayName: params.sender.displayName.trim() }
: {}),
...(params.sender.metadata &&
typeof params.sender.metadata === "object" &&
!Array.isArray(params.sender.metadata)
? { metadata: params.sender.metadata }
: {}),
}
: undefined;
return {
text: typeof params?.text === "string" ? params.text : "",
roomKey: trimmedRoomId,
mode: params?.mode === "simple" ? "simple" : "power",
channelType: params?.channelType === "GROUP" ? "GROUP" : "DM",
source,
...(sender && Object.keys(sender).length > 0 ? { sender } : {}),
...(params?.metadata &&
typeof params.metadata === "object" &&
!Array.isArray(params.metadata)
? { metadata: params.metadata }
: {}),
};
}
export interface CloudAgentConfig {
/** Health endpoint port. Default: 2138 */
port?: number;
/** Bridge server port. Default: 18790 */
bridgePort?: number;
/**
* If set, the bridge server requires `Authorization: Bearer <secret>`.
* Omit or pass empty string to disable auth.
*/
bridgeSecret?: string;
/** Max request body size in bytes. Default: 1 MB */
maxBodyBytes?: number;
/** Max memories kept in state. 0 = unlimited. Default: 0 */
maxMemories?: number;
/**
* Whether processMessage/processMessageStream accept a chat mode param.
* When false the mode parameter is ignored (template behaviour).
*/
enableChatMode?: boolean;
}
interface AgentRuntime {
processMessage: (params: BridgeRpcParams) => Promise<BridgeMessageResult>;
processMessageStream: (
params: BridgeRpcParams,
onChunk: (chunk: string) => void,
) => Promise<BridgeMessageResult>;
getMemories: () => Array<Record<string, unknown>>;
getConfig: () => Record<string, unknown>;
}
// ─── Main entry ─────────────────────────────────────────────────────────
export function startCloudAgent(userConfig: CloudAgentConfig = {}): void {
const PORT = userConfig.port ?? Number(process.env.PORT ?? "2138");
const BRIDGE_PORT =
userConfig.bridgePort ?? Number(process.env.BRIDGE_PORT ?? "18790");
const BRIDGE_SECRET = userConfig.bridgeSecret || crypto.randomUUID();
const bridgeSecretGenerated = !userConfig.bridgeSecret;
const MAX_BODY_BYTES = userConfig.maxBodyBytes ?? 1_048_576;
const MAX_MEMORIES = userConfig.maxMemories ?? 0;
const enableChatMode = userConfig.enableChatMode ?? false;
let agentRuntime: AgentRuntime | null = null;
/** In-memory state that persists across snapshots. */
const state = {
memories: [] as Array<Record<string, unknown>>,
config: {} as Record<string, unknown>,
workspaceFiles: {} as Record<string, string>,
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
};
/** Trim memories array to MAX_MEMORIES, removing oldest entries first. */
function trimMemories(): void {
if (MAX_MEMORIES > 0 && state.memories.length > MAX_MEMORIES) {
state.memories.splice(0, state.memories.length - MAX_MEMORIES);
}
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise<string>((resolve, reject) => {
let body = "";
let totalBytes = 0;
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (MAX_BODY_BYTES > 0 && totalBytes > MAX_BODY_BYTES) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
body += chunk;
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
// ─── elizaOS Runtime ──────────────────────────────────────────────────
async function initRuntime(): Promise<void> {
const elizaAvailable = await import("@elizaos/core")
.then(() => true)
.catch(() => false);
if (elizaAvailable) {
const {
AgentRuntime: AgentRuntimeCtor,
createCharacter,
createMessageMemory,
stringToUuid,
ChannelType,
} = await import("@elizaos/core");
const character = createCharacter({
name: process.env.AGENT_NAME ?? "CloudAgent",
bio: "An elizaOS agent running in the cloud.",
settings: {
...(process.env.DATABASE_URL
? {
POSTGRES_URL: process.env.DATABASE_URL,
DATABASE_URL: process.env.DATABASE_URL,
}
: {}),
},
secrets: {
...(process.env.ELIZAOS_CLOUD_API_KEY
? { ELIZAOS_CLOUD_API_KEY: process.env.ELIZAOS_CLOUD_API_KEY }
: {}),
...(process.env.OPENAI_API_KEY
? { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
: {}),
...(process.env.ANTHROPIC_API_KEY
? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }
: {}),
...(process.env.GOOGLE_API_KEY
? { GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
: {}),
...(process.env.XAI_API_KEY
? { XAI_API_KEY: process.env.XAI_API_KEY }
: {}),
...(process.env.GROQ_API_KEY
? { GROQ_API_KEY: process.env.GROQ_API_KEY }
: {}),
},
});
const plugins = [];
const cloudPlugin = await import("@elizaos/plugin-elizacloud")
.then((m) => m.default)
.catch(logPluginLoadFailure("@elizaos/plugin-elizacloud"));
if (cloudPlugin) plugins.push(cloudPlugin);
const sqlPlugin = await import("@elizaos/plugin-sql")
.then((m) => m.default)
.catch(logPluginLoadFailure("@elizaos/plugin-sql"));
if (sqlPlugin) plugins.push(sqlPlugin);
const workflowPlugin = await import("@elizaos/plugin-workflow")
.then((m) => m.default)
.catch(logPluginLoadFailure("@elizaos/plugin-workflow"));
if (workflowPlugin) plugins.push(workflowPlugin);
const runtime = new AgentRuntimeCtor({ character, plugins });
await runtime.initialize();
const runtimeWithBridge = runtime as typeof runtime & {
ensureWorldExists?: (world: Record<string, unknown>) => Promise<void>;
ensureRoomExists?: (room: Record<string, unknown>) => Promise<void>;
ensureParticipantInRoom?: (
entityId: ReturnType<typeof stringToUuid>,
roomId: ReturnType<typeof stringToUuid>,
) => Promise<void>;
getEntityById?: (
entityId: ReturnType<typeof stringToUuid>,
) => Promise<Record<string, unknown> | null>;
createEntity?: (entity: Record<string, unknown>) => Promise<void>;
updateEntity?: (entity: Record<string, unknown>) => Promise<void>;
};
const ensureBridgeContext = async (params: BridgeRpcParams) => {
const normalized = normalizeBridgeMessage(params);
const worldId = stringToUuid(
`${normalized.source}-${normalized.channelType.toLowerCase()}-world`,
);
const serverId = stringToUuid(`${normalized.source}-bridge-server`);
const roomId = stringToUuid(
`${normalized.source}-bridge-room-${normalized.roomKey}`,
);
const entityKey =
normalized.sender?.id ??
normalized.sender?.username ??
`${normalized.source}-bridge-user`;
const entityId = stringToUuid(
`${normalized.source}-bridge-user-${entityKey}`,
);
const channelType =
normalized.channelType === "GROUP"
? ChannelType.GROUP
: ChannelType.DM;
const displayName =
normalized.sender?.displayName ??
normalized.sender?.username ??
"BridgeUser";
if (typeof runtimeWithBridge.ensureWorldExists === "function") {
await runtimeWithBridge.ensureWorldExists({
id: worldId,
name: normalized.source === "discord" ? "Discord" : "Cloud Bridge",
agentId: runtime.agentId,
serverId,
});
}
if (typeof runtimeWithBridge.ensureRoomExists === "function") {
await runtimeWithBridge.ensureRoomExists({
id: roomId,
name: normalized.roomKey,
type: channelType,
channelId: normalized.roomKey,
worldId,
serverId,
agentId: runtime.agentId,
source: normalized.source,
});
}
const entityMetadata =
normalized.sender?.metadata &&
typeof normalized.sender.metadata === "object" &&
!Array.isArray(normalized.sender.metadata)
? normalized.sender.metadata
: undefined;
const entityPayload = {
id: entityId,
agentId: runtime.agentId,
names: Array.from(
new Set(
[displayName, normalized.sender?.username].filter(
(value): value is string => Boolean(value),
),
),
),
...(entityMetadata ? { metadata: entityMetadata } : {}),
};
try {
if (
typeof runtimeWithBridge.getEntityById === "function" &&
typeof runtimeWithBridge.updateEntity === "function"
) {
const existingEntity =
await runtimeWithBridge.getEntityById(entityId);
if (existingEntity) {
await runtimeWithBridge.updateEntity({
...existingEntity,
...entityPayload,
names:
entityPayload.names.length > 0
? entityPayload.names
: ((existingEntity.names as string[] | undefined) ?? []),
});
} else if (typeof runtimeWithBridge.createEntity === "function") {
await runtimeWithBridge.createEntity(entityPayload);
}
} else if (typeof runtimeWithBridge.createEntity === "function") {
await runtimeWithBridge.createEntity(entityPayload);
}
} catch {
// Best-effort entity sync. The room flow still works if the entity already exists.
}
if (typeof runtimeWithBridge.ensureParticipantInRoom === "function") {
await Promise.all([
runtimeWithBridge.ensureParticipantInRoom(runtime.agentId, roomId),
runtimeWithBridge.ensureParticipantInRoom(entityId, roomId),
]);
}
return { normalized, entityId, roomId, channelType };
};
agentRuntime = {
processMessage: async (
params: BridgeRpcParams,
): Promise<BridgeMessageResult> => {
const { normalized, entityId, roomId, channelType } =
await ensureBridgeContext(params);
const message = createMessageMemory({
id: crypto.randomUUID() as ReturnType<typeof stringToUuid>,
entityId,
roomId,
content: {
text: normalized.text,
...(enableChatMode
? {
mode: normalized.mode,
simple: normalized.mode === "simple",
}
: {}),
source: normalized.source,
channelType,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
},
});
const response: BridgeMessageResult = { text: "" };
await runtime.messageService?.handleMessage(
runtime,
message,
async (content) => {
appendBridgeCallbackContent(
response,
content as BridgeCallbackContent,
);
return [];
},
);
state.lastActivityAt = new Date().toISOString();
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
state.memories.push({
role: "assistant",
text: bridgeResultText(response),
timestamp: Date.now(),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
});
trimMemories();
return {
text: bridgeResultText(response),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
};
},
processMessageStream: async (
params: BridgeRpcParams,
onChunk: (chunk: string) => void,
): Promise<BridgeMessageResult> => {
const { normalized, entityId, roomId, channelType } =
await ensureBridgeContext(params);
const message = createMessageMemory({
id: crypto.randomUUID() as ReturnType<typeof stringToUuid>,
entityId,
roomId,
content: {
text: normalized.text,
...(enableChatMode
? {
mode: normalized.mode,
simple: normalized.mode === "simple",
}
: {}),
source: normalized.source,
channelType,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
},
});
const response: BridgeMessageResult = { text: "" };
await runtime.messageService?.handleMessage(
runtime,
message,
async (content) => {
const previousTextLength = response.text.length;
appendBridgeCallbackContent(
response,
content as BridgeCallbackContent,
);
if (response.text.length > previousTextLength) {
onChunk(response.text.slice(previousTextLength));
}
return [];
},
);
state.lastActivityAt = new Date().toISOString();
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
state.memories.push({
role: "assistant",
text: bridgeResultText(response),
timestamp: Date.now(),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
});
trimMemories();
return {
text: bridgeResultText(response),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
};
},
getMemories: () => state.memories,
getConfig: () => state.config,
};
logger.info("elizaOS runtime initialized with real agent");
} else {
logger.warn("@elizaos/core not available, running in echo mode");
agentRuntime = {
processMessage: async (
params: BridgeRpcParams,
): Promise<BridgeMessageResult> => {
const normalized = normalizeBridgeMessage(params);
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
const reply = `[echo] ${normalized.text}`;
state.memories.push({
role: "assistant",
text: reply,
timestamp: Date.now(),
});
trimMemories();
return { text: reply };
},
processMessageStream: async (
params: BridgeRpcParams,
onChunk: (chunk: string) => void,
): Promise<BridgeMessageResult> => {
const normalized = normalizeBridgeMessage(params);
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
const reply = `[echo] ${normalized.text}`;
onChunk(reply);
state.memories.push({
role: "assistant",
text: reply,
timestamp: Date.now(),
});
trimMemories();
return { text: reply };
},
getMemories: () => state.memories,
getConfig: () => state.config,
};
}
}
// ─── Health endpoint ──────────────────────────────────────────────────
/** Consider the runtime hung if no activity for 10 minutes after init. */
const HUNG_RUNTIME_THRESHOLD_MS = 10 * 60_000;
const healthServer = http.createServer((req, res) => {
if (
req.method === "GET" &&
(req.url === "/health" || req.url === "/api/health")
) {
const lastActivityAge =
Date.now() - new Date(state.lastActivityAt).getTime();
const possiblyHung =
agentRuntime !== null &&
state.memories.length > 0 &&
lastActivityAge > HUNG_RUNTIME_THRESHOLD_MS;
let status: string;
if (!agentRuntime) {
status = "initializing";
} else if (possiblyHung) {
status = "possibly_hung";
} else {
status = "healthy";
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status,
uptime: process.uptime(),
startedAt: state.startedAt,
lastActivityAt: state.lastActivityAt,
memoryUsage: process.memoryUsage().rss,
runtimeReady: agentRuntime !== null,
}),
);
return;
}
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
service: "elizaos-cloud-agent",
status: "running",
}),
);
return;
}
res.writeHead(404);
res.end("Not Found");
});
healthServer.listen(PORT, "0.0.0.0", () => {
logger.info(`Health endpoint listening on port ${PORT}`);
});
// ─── Bridge HTTP server ───────────────────────────────────────────────
const bridgeServer = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
// Auth check (only when BRIDGE_SECRET is configured)
if (BRIDGE_SECRET) {
const authHeader = req.headers.authorization ?? "";
if (authHeader !== `Bearer ${BRIDGE_SECRET}`) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
}
if (req.method === "POST" && req.url === "/api/snapshot") {
res.writeHead(200);
res.end(
JSON.stringify({
memories: state.memories,
config: state.config,
workspaceFiles: state.workspaceFiles,
timestamp: new Date().toISOString(),
}),
);
return;
}
if (req.method === "POST" && req.url === "/api/restore") {
const body = await readBody(req);
let incoming: Partial<typeof state>;
try {
incoming = JSON.parse(body) as Partial<typeof state>;
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (incoming.memories) state.memories = incoming.memories;
if (incoming.config) state.config = incoming.config;
if (incoming.workspaceFiles)
state.workspaceFiles = incoming.workspaceFiles;
logger.info("State restored from snapshot");
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
return;
}
// ── SSE streaming endpoint ────────────────────────────────────────
if (req.method === "POST" && req.url === "/bridge/stream") {
if (!agentRuntime) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Agent runtime not ready" }));
return;
}
const body = await readBody(req);
let rpc: {
jsonrpc: string;
id?: string | number;
method?: string;
params?: BridgeRpcParams;
};
try {
rpc = JSON.parse(body);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (rpc.method !== "message.send") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Only message.send is streamable" }));
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
const sendEvent = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
sendEvent("connected", { rpcId: rpc.id, timestamp: Date.now() });
const response = await agentRuntime.processMessageStream(
rpc.params ?? {},
(chunk: string) => {
sendEvent("chunk", { text: chunk });
},
);
sendEvent("done", {
rpcId: rpc.id,
timestamp: Date.now(),
...(response.failureKind ? { failureKind: response.failureKind } : {}),
});
res.end();
return;
}
if (req.method === "POST" && req.url === "/bridge") {
const body = await readBody(req);
let rpc: {
jsonrpc: string;
id?: string | number;
method?: string;
params?: BridgeRpcParams;
};
try {
rpc = JSON.parse(body);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (rpc.method === "message.send") {
if (!agentRuntime) {
res.writeHead(503);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: {
code: -32000,
message: "Agent runtime not ready",
},
}),
);
return;
}
const response = await agentRuntime.processMessage(rpc.params ?? {});
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
result: {
text: response.text,
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
metadata: {
timestamp: Date.now(),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
},
},
}),
);
return;
}
if (rpc.method === "status.get") {
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
result: {
status: agentRuntime ? "running" : "initializing",
uptime: process.uptime(),
memoriesCount: state.memories.length,
startedAt: state.startedAt,
},
}),
);
return;
}
if (rpc.method === "heartbeat") {
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
method: "heartbeat.ack",
params: { timestamp: Date.now() },
}),
);
return;
}
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: {
code: -32601,
message: `Method not found: ${rpc.method}`,
},
}),
);
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: "Not Found" }));
});
const bridgeBindAddress = bridgeSecretGenerated ? "127.0.0.1" : "0.0.0.0";
bridgeServer.listen(BRIDGE_PORT, bridgeBindAddress, () => {
logger.info(
`Bridge server listening on ${bridgeBindAddress}:${BRIDGE_PORT}`,
);
if (bridgeSecretGenerated) {
logger.warn(
"CRITICAL: No BRIDGE_SECRET configured — generated ephemeral secret and bound to 127.0.0.1 only",
);
logger.info(`Generated BRIDGE_SECRET: ${BRIDGE_SECRET}`);
}
});
// ─── Startup ──────────────────────────────────────────────────────────
function shutdown() {
logger.info("Shutting down...");
healthServer.close();
bridgeServer.close();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// Crash guards. This file is bundled by esbuild for the cloud-agent image,
// which only installs @elizaos/core + @elizaos/plugin-sql, so we cannot import
// @elizaos/shared's installProcessCrashGuards here — the guards are inlined.
// A rejected background promise must never take down the container; a truly
// uncaught exception exits non-zero so the orchestrator (Docker
// `--restart unless-stopped` / K8s `restartPolicy: Always`) relaunches a clean
// container. Exit code matches @elizaos/shared RESTART_EXIT_CODE.
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled promise rejection (non-fatal)", {
err:
reason instanceof Error
? (reason.stack ?? reason.message)
: String(reason),
});
});
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception — exiting for orchestrator restart", {
err:
error instanceof Error ? (error.stack ?? error.message) : String(error),
});
process.exit(CLOUD_AGENT_RESTART_EXIT_CODE);
});
initRuntime()
.then(() => {
logger.info("Ready");
})
.catch((err) => {
logger.error("Runtime init failed", {
err: err instanceof Error ? err.message : String(err),
});
process.exit(1);
});
}