mirror of
https://github.com/TencentCloud/TencentDB-Agent-Memory
synced 2026-07-10 12:34:27 +00:00
609 lines
21 KiB
TypeScript
609 lines
21 KiB
TypeScript
/**
|
|
* TDAI Gateway — HTTP server for the Hermes sidecar.
|
|
*
|
|
* Exposes TDAI Core capabilities as HTTP endpoints:
|
|
* GET /health — Health check
|
|
* POST /recall — Memory recall (prefetch)
|
|
* POST /capture — Conversation capture (sync_turn)
|
|
* POST /search/memories — L1 memory search
|
|
* POST /search/conversations — L0 conversation search
|
|
* POST /session/end — Session end + flush
|
|
* POST /seed — Batch seed historical conversations (L0 → L1)
|
|
*
|
|
* Built with Node.js native `http` module — no Express/Fastify dependency.
|
|
* Designed to run as a managed sidecar alongside Hermes.
|
|
*/
|
|
|
|
import http from "node:http";
|
|
import { URL } from "node:url";
|
|
import { timingSafeEqual } from "node:crypto";
|
|
import { TdaiCore } from "../core/tdai-core.js";
|
|
import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js";
|
|
import { loadGatewayConfig } from "./config.js";
|
|
import type { GatewayConfig } from "./config.js";
|
|
import { initDataDirectories } from "../utils/pipeline-factory.js";
|
|
import { SessionFilter } from "../utils/session-filter.js";
|
|
import type {
|
|
HealthResponse,
|
|
RecallRequest,
|
|
RecallResponse,
|
|
CaptureRequest,
|
|
CaptureResponse,
|
|
MemorySearchRequest,
|
|
MemorySearchResponse,
|
|
ConversationSearchRequest,
|
|
ConversationSearchResponse,
|
|
SessionEndRequest,
|
|
SessionEndResponse,
|
|
SeedRequest,
|
|
SeedResponse,
|
|
GatewayErrorResponse,
|
|
} from "./types.js";
|
|
import type { Logger } from "../core/types.js";
|
|
import { validateAndNormalizeRaw, fillTimestamps, SeedValidationError } from "../core/seed/input.js";
|
|
import { executeSeed } from "../core/seed/seed-runtime.js";
|
|
import type { SeedProgress } from "../core/seed/types.js";
|
|
|
|
const TAG = "[tdai-gateway]";
|
|
const VERSION = "0.1.0";
|
|
|
|
// ============================
|
|
// Console logger (for standalone gateway — no OpenClaw logger available)
|
|
// ============================
|
|
|
|
function createConsoleLogger(): Logger {
|
|
return {
|
|
debug: (msg: string) => console.debug(`${TAG} ${msg}`),
|
|
info: (msg: string) => console.info(`${TAG} ${msg}`),
|
|
warn: (msg: string) => console.warn(`${TAG} ${msg}`),
|
|
error: (msg: string) => console.error(`${TAG} ${msg}`),
|
|
};
|
|
}
|
|
|
|
// ============================
|
|
// Request body parser
|
|
// ============================
|
|
|
|
async function parseJsonBody<T>(req: http.IncomingMessage): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => {
|
|
try {
|
|
const body = Buffer.concat(chunks).toString("utf-8");
|
|
resolve(JSON.parse(body) as T);
|
|
} catch (err) {
|
|
reject(new Error("Invalid JSON body"));
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(res: http.ServerResponse, status: number, body: unknown): void {
|
|
const json = JSON.stringify(body);
|
|
res.writeHead(status, {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": Buffer.byteLength(json),
|
|
});
|
|
res.end(json);
|
|
}
|
|
|
|
function sendError(res: http.ServerResponse, status: number, message: string): void {
|
|
sendJson(res, status, { error: message } satisfies GatewayErrorResponse);
|
|
}
|
|
|
|
/**
|
|
* Constant-time string equality for secrets.
|
|
*
|
|
* Returns `false` on any length mismatch (without comparing bytes), and uses
|
|
* `crypto.timingSafeEqual` for the equal-length case so that an attacker
|
|
* probing the API key cannot use response timing to learn a prefix match.
|
|
*/
|
|
function safeEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a, "utf-8");
|
|
const bb = Buffer.from(b, "utf-8");
|
|
if (ab.length !== bb.length) return false;
|
|
return timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
// ============================
|
|
// Gateway Server
|
|
// ============================
|
|
|
|
export class TdaiGateway {
|
|
private config: GatewayConfig;
|
|
private logger: Logger;
|
|
private core: TdaiCore;
|
|
private server: http.Server | null = null;
|
|
private startTime = Date.now();
|
|
|
|
constructor(configOverrides?: Partial<GatewayConfig>) {
|
|
this.config = loadGatewayConfig(configOverrides);
|
|
this.logger = createConsoleLogger();
|
|
|
|
// Create host adapter
|
|
const adapter = new StandaloneHostAdapter({
|
|
dataDir: this.config.data.baseDir,
|
|
llmConfig: this.config.llm,
|
|
logger: this.logger,
|
|
platform: "gateway",
|
|
});
|
|
|
|
// Create core
|
|
this.core = new TdaiCore({
|
|
hostAdapter: adapter,
|
|
config: this.config.memory,
|
|
sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Start the Gateway HTTP server.
|
|
*/
|
|
async start(): Promise<void> {
|
|
// Initialize data directories
|
|
initDataDirectories(this.config.data.baseDir);
|
|
|
|
// Initialize core
|
|
await this.core.initialize();
|
|
|
|
// Create HTTP server
|
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
|
|
const { port, host } = this.config.server;
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.server!.listen(port, host, () => {
|
|
this.startTime = Date.now();
|
|
this.logger.info(`Gateway listening on http://${host}:${port}`);
|
|
this.logSecurityPosture();
|
|
resolve();
|
|
});
|
|
this.server!.on("error", reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Emit a one-shot security posture summary at startup.
|
|
*
|
|
* Goals:
|
|
* 1. Make the "auth disabled" state highly visible to anyone reading logs
|
|
* (this is the documented default, but operators must know it before
|
|
* they expose the port).
|
|
* 2. Loudly warn when the gateway is bound to anything other than the
|
|
* loopback interface without an API key — that exact combination is
|
|
* what the security audit flagged as a real exposure.
|
|
* 3. Never log the key itself.
|
|
*/
|
|
private logSecurityPosture(): void {
|
|
const { host, apiKey, corsOrigins } = this.config.server;
|
|
const authOn = !!apiKey;
|
|
const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
|
|
this.logger.info(
|
|
`Security posture: auth=${authOn ? "ENABLED (Bearer)" : "disabled"} ` +
|
|
`host=${host} cors=${corsOrigins.length === 0 ? "no-headers" : corsOrigins.includes("*") ? "wildcard(*)" : `allowlist(${corsOrigins.length})`}`
|
|
);
|
|
|
|
if (!authOn) {
|
|
this.logger.warn(
|
|
"TDAI_GATEWAY_API_KEY is NOT set — all routes except GET /health are " +
|
|
"open to anyone who can reach this port. This is the legacy default. " +
|
|
"Set TDAI_GATEWAY_API_KEY (or server.apiKey in tdai-gateway.yaml) and " +
|
|
"pass `Authorization: Bearer <key>` from clients before exposing the " +
|
|
"gateway beyond the loopback interface."
|
|
);
|
|
}
|
|
if (!loopback && !authOn) {
|
|
this.logger.warn(
|
|
`Gateway is bound to ${host} (non-loopback) WITHOUT an API key. ` +
|
|
"Every /capture, /search/conversations, /recall, /seed call from the " +
|
|
"network is currently unauthenticated. Bind to 127.0.0.1, or set " +
|
|
"TDAI_GATEWAY_API_KEY, before continuing."
|
|
);
|
|
}
|
|
if (corsOrigins.includes("*")) {
|
|
this.logger.warn(
|
|
"CORS allow-list contains '*' — every browser origin can call this " +
|
|
"gateway. Restrict server.corsOrigins to a concrete allow-list for any " +
|
|
"non-local deployment."
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gracefully stop the Gateway.
|
|
*/
|
|
async stop(): Promise<void> {
|
|
this.logger.info("Shutting down gateway...");
|
|
|
|
if (this.server) {
|
|
await new Promise<void>((resolve) => {
|
|
this.server!.close(() => resolve());
|
|
});
|
|
}
|
|
|
|
await this.core.destroy();
|
|
this.logger.info("Gateway stopped");
|
|
}
|
|
|
|
// ============================
|
|
// Request router
|
|
// ============================
|
|
|
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
const method = req.method?.toUpperCase() ?? "GET";
|
|
const pathname = url.pathname;
|
|
|
|
// Apply CORS headers based on configured allow-list (empty → no headers).
|
|
this.applyCorsHeaders(req, res);
|
|
|
|
if (method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// GET /health is always reachable without auth — operators and
|
|
// orchestrators (k8s liveness, docker health-check) rely on it being
|
|
// an unconditionally cheap probe.
|
|
if (method === "GET" && pathname === "/health") {
|
|
return this.handleHealth(res);
|
|
}
|
|
|
|
// All other routes go through the optional auth gate. When apiKey is
|
|
// unset the gate is a no-op (preserves legacy open behaviour) — the
|
|
// startup WARN in `logSecurityPosture` covers that case.
|
|
if (!this.checkAuth(req, res)) return;
|
|
|
|
switch (`${method} ${pathname}`) {
|
|
case "POST /recall":
|
|
return await this.handleRecall(req, res);
|
|
case "POST /capture":
|
|
return await this.handleCapture(req, res);
|
|
case "POST /search/memories":
|
|
return await this.handleSearchMemories(req, res);
|
|
case "POST /search/conversations":
|
|
return await this.handleSearchConversations(req, res);
|
|
case "POST /session/end":
|
|
return await this.handleSessionEnd(req, res);
|
|
case "POST /seed":
|
|
return await this.handleSeed(req, res);
|
|
default:
|
|
sendError(res, 404, `Not found: ${method} ${pathname}`);
|
|
}
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
this.logger.error(`Request error [${method} ${pathname}]: ${msg}`);
|
|
sendError(res, 500, msg);
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// Auth & CORS gates (opt-in, off by default)
|
|
// ============================
|
|
|
|
/**
|
|
* Verify the `Authorization: Bearer <apiKey>` header against the configured
|
|
* shared secret using a constant-time comparison.
|
|
*
|
|
* When `server.apiKey` is unset (`undefined`), this returns `true` without
|
|
* inspecting the request — this is the documented default and matches the
|
|
* pre-existing open behaviour. Operators are reminded of this at startup
|
|
* via `logSecurityPosture`.
|
|
*
|
|
* Returns `false` (and writes 401) when the token is missing, malformed, or
|
|
* does not match. Callers must short-circuit on `false`.
|
|
*/
|
|
private checkAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean {
|
|
const expected = this.config.server.apiKey;
|
|
if (!expected) return true; // auth disabled — default behaviour
|
|
|
|
const header = req.headers["authorization"];
|
|
if (typeof header !== "string" || !header.startsWith("Bearer ")) {
|
|
sendError(res, 401, "Unauthorized: missing Bearer token");
|
|
return false;
|
|
}
|
|
const provided = header.slice("Bearer ".length).trim();
|
|
if (!provided || !safeEqual(provided, expected)) {
|
|
sendError(res, 401, "Unauthorized: invalid token");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Echo `Access-Control-Allow-Origin` (and friends) only for whitelisted
|
|
* origins. With no list configured we emit no CORS headers at all, which
|
|
* makes the browser refuse the cross-origin request as desired.
|
|
*
|
|
* The single-entry list `["*"]` opts back into permissive CORS (development
|
|
* use only; the startup log flags this loudly).
|
|
*/
|
|
private applyCorsHeaders(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
const allow = this.config.server.corsOrigins ?? [];
|
|
if (allow.length === 0) return; // strict default — no headers
|
|
|
|
if (allow.includes("*")) {
|
|
// Wildcard — preserves the legacy permissive behaviour for callers that
|
|
// opt in explicitly via config. Note: with wildcard we deliberately do
|
|
// not echo back the request Origin and do not send `Vary: Origin`,
|
|
// mirroring how the gateway behaved before this change.
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
return;
|
|
}
|
|
|
|
const requestOrigin = req.headers["origin"];
|
|
if (typeof requestOrigin !== "string" || !allow.includes(requestOrigin)) {
|
|
// Origin not in allow-list — emit no CORS headers; browser will block.
|
|
// Always set Vary so caches don't poison responses across origins.
|
|
res.setHeader("Vary", "Origin");
|
|
return;
|
|
}
|
|
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
res.setHeader("Vary", "Origin");
|
|
}
|
|
|
|
// ============================
|
|
// Route handlers
|
|
// ============================
|
|
|
|
private handleHealth(res: http.ServerResponse): void {
|
|
const response: HealthResponse = {
|
|
status: this.core.getVectorStore() ? "ok" : "degraded",
|
|
version: VERSION,
|
|
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
|
stores: {
|
|
vectorStore: !!this.core.getVectorStore(),
|
|
embeddingService: !!this.core.getEmbeddingService(),
|
|
},
|
|
};
|
|
sendJson(res, 200, response);
|
|
}
|
|
|
|
private async handleRecall(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<RecallRequest>(req);
|
|
|
|
if (!body.query || !body.session_key) {
|
|
sendError(res, 400, "Missing required fields: query, session_key");
|
|
return;
|
|
}
|
|
|
|
const startMs = Date.now();
|
|
const result = await this.core.handleBeforeRecall(body.query, body.session_key);
|
|
const elapsed = Date.now() - startMs;
|
|
|
|
this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`);
|
|
|
|
const response: RecallResponse = {
|
|
context: result.appendSystemContext ?? "",
|
|
strategy: result.recallStrategy,
|
|
memory_count: result.recalledL1Memories?.length ?? 0,
|
|
};
|
|
sendJson(res, 200, response);
|
|
}
|
|
|
|
private async handleCapture(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<CaptureRequest>(req);
|
|
|
|
if (!body.user_content || !body.assistant_content || !body.session_key) {
|
|
sendError(res, 400, "Missing required fields: user_content, assistant_content, session_key");
|
|
return;
|
|
}
|
|
|
|
const startMs = Date.now();
|
|
const result = await this.core.handleTurnCommitted({
|
|
userText: body.user_content,
|
|
assistantText: body.assistant_content,
|
|
messages: body.messages ?? [
|
|
{ role: "user", content: body.user_content },
|
|
{ role: "assistant", content: body.assistant_content },
|
|
],
|
|
sessionKey: body.session_key,
|
|
sessionId: body.session_id,
|
|
});
|
|
const elapsed = Date.now() - startMs;
|
|
|
|
this.logger.info(`Capture completed in ${elapsed}ms: l0=${result.l0RecordedCount}`);
|
|
|
|
const response: CaptureResponse = {
|
|
l0_recorded: result.l0RecordedCount,
|
|
scheduler_notified: result.schedulerNotified,
|
|
};
|
|
sendJson(res, 200, response);
|
|
}
|
|
|
|
private async handleSearchMemories(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<MemorySearchRequest>(req);
|
|
|
|
if (!body.query) {
|
|
sendError(res, 400, "Missing required field: query");
|
|
return;
|
|
}
|
|
|
|
const result = await this.core.searchMemories({
|
|
query: body.query,
|
|
limit: body.limit,
|
|
type: body.type,
|
|
scene: body.scene,
|
|
});
|
|
|
|
const response: MemorySearchResponse = {
|
|
results: result.text,
|
|
total: result.total,
|
|
strategy: result.strategy,
|
|
};
|
|
sendJson(res, 200, response);
|
|
}
|
|
|
|
private async handleSearchConversations(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<ConversationSearchRequest>(req);
|
|
|
|
if (!body.query) {
|
|
sendError(res, 400, "Missing required field: query");
|
|
return;
|
|
}
|
|
|
|
const result = await this.core.searchConversations({
|
|
query: body.query,
|
|
limit: body.limit,
|
|
sessionKey: body.session_key,
|
|
});
|
|
|
|
const response: ConversationSearchResponse = {
|
|
results: result.text,
|
|
total: result.total,
|
|
};
|
|
sendJson(res, 200, response);
|
|
}
|
|
|
|
private async handleSessionEnd(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<SessionEndRequest>(req);
|
|
|
|
if (!body.session_key) {
|
|
sendError(res, 400, "Missing required field: session_key");
|
|
return;
|
|
}
|
|
|
|
await this.core.handleSessionEnd(body.session_key);
|
|
|
|
const response: SessionEndResponse = { flushed: true };
|
|
sendJson(res, 200, response);
|
|
}
|
|
|
|
private async handleSeed(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const body = await parseJsonBody<SeedRequest>(req);
|
|
|
|
if (!body.data) {
|
|
sendError(res, 400, "Missing required field: data");
|
|
return;
|
|
}
|
|
|
|
// Validate and normalize input (reuses seed CLI's validation layers 2-6)
|
|
let input;
|
|
try {
|
|
input = validateAndNormalizeRaw(body.data, {
|
|
sessionKey: body.session_key,
|
|
strictRoundRole: body.strict_round_role,
|
|
autoFillTimestamps: body.auto_fill_timestamps ?? true,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof SeedValidationError) {
|
|
sendJson(res, 400, {
|
|
error: err.message,
|
|
validation_errors: err.errors,
|
|
});
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
this.logger.info(
|
|
`Seed request: ${input.sessions.length} session(s), ` +
|
|
`${input.totalRounds} round(s), ${input.totalMessages} message(s)`,
|
|
);
|
|
|
|
// Resolve output directory: use gateway's data dir with a timestamped subfolder
|
|
const now = new Date();
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
const ts =
|
|
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` +
|
|
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
const outputDir = `${this.config.data.baseDir}/seed-${ts}`;
|
|
|
|
// Merge config overrides if provided
|
|
// Start with the base memory config + inject llm config from gateway settings
|
|
const baseConfig = this.config.memory as unknown as Record<string, unknown>;
|
|
let pluginConfig: Record<string, unknown> = {
|
|
...baseConfig,
|
|
llm: {
|
|
enabled: true,
|
|
baseUrl: this.config.llm.baseUrl,
|
|
apiKey: this.config.llm.apiKey,
|
|
model: this.config.llm.model,
|
|
maxTokens: this.config.llm.maxTokens,
|
|
timeoutMs: this.config.llm.timeoutMs,
|
|
},
|
|
};
|
|
if (body.config_override) {
|
|
for (const key of Object.keys(body.config_override)) {
|
|
const baseVal = pluginConfig[key];
|
|
const overVal = body.config_override[key];
|
|
if (baseVal && typeof baseVal === "object" && !Array.isArray(baseVal) &&
|
|
overVal && typeof overVal === "object" && !Array.isArray(overVal)) {
|
|
pluginConfig[key] = { ...(baseVal as Record<string, unknown>), ...(overVal as Record<string, unknown>) };
|
|
} else {
|
|
pluginConfig[key] = overVal;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Execute seed pipeline (blocking — this may take minutes for large inputs)
|
|
const summary = await executeSeed(input, {
|
|
outputDir,
|
|
openclawConfig: {},
|
|
pluginConfig,
|
|
logger: this.logger as import("../utils/pipeline-factory.js").PipelineLogger,
|
|
onProgress: (progress: SeedProgress) => {
|
|
this.logger.debug?.(
|
|
`Seed progress: [${progress.currentRound}/${progress.totalRounds}] ` +
|
|
`session=${progress.sessionKey} stage=${progress.stage}`,
|
|
);
|
|
},
|
|
});
|
|
|
|
this.logger.info(
|
|
`Seed complete: sessions=${summary.sessionsProcessed}, rounds=${summary.roundsProcessed}, ` +
|
|
`l0=${summary.l0RecordedCount}, duration=${(summary.durationMs / 1000).toFixed(1)}s`,
|
|
);
|
|
|
|
const response: SeedResponse = {
|
|
sessions_processed: summary.sessionsProcessed,
|
|
rounds_processed: summary.roundsProcessed,
|
|
messages_processed: summary.messagesProcessed,
|
|
l0_recorded: summary.l0RecordedCount,
|
|
duration_ms: summary.durationMs,
|
|
output_dir: summary.outputDir,
|
|
};
|
|
sendJson(res, 200, response);
|
|
}
|
|
}
|
|
|
|
// ============================
|
|
// CLI entry point
|
|
// ============================
|
|
|
|
/**
|
|
* Start the gateway from the command line.
|
|
* Usage: node --import tsx src/gateway/server.ts
|
|
*/
|
|
async function main(): Promise<void> {
|
|
const gateway = new TdaiGateway();
|
|
|
|
// Graceful shutdown
|
|
const shutdown = async () => {
|
|
await gateway.stop();
|
|
process.exit(0);
|
|
};
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
await gateway.start();
|
|
}
|
|
|
|
// Auto-start when run directly
|
|
const isMain = process.argv[1]?.endsWith("server.ts") || process.argv[1]?.endsWith("server.js");
|
|
if (isMain) {
|
|
main().catch((err) => {
|
|
console.error("Gateway startup failed:", err);
|
|
process.exit(1);
|
|
});
|
|
}
|