diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml index cd46f29..04f9f1b 100644 --- a/docker-compose.synology.yml +++ b/docker-compose.synology.yml @@ -30,6 +30,7 @@ services: NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: ${NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS:-43200} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://172.22.0.222:18080} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://172.22.0.222:18090} + NODEDC_ENGINE_INTERNAL_URL: ${NODEDC_ENGINE_INTERNAL_URL:-http://172.22.0.222:3001} NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN} depends_on: postgres: diff --git a/src/app.ts b/src/app.ts index 9015c03..4340c41 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { createPool, DatabaseNotConfiguredError } from "./db/pool.js"; import { ToolExecutionInputError } from "./mcp/tool-runtime.js"; import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; +import { registerEngineGatewayRoutes } from "./routes/engine.js"; import { registerHealthRoutes } from "./routes/health.js"; import { registerInstallerRoutes } from "./routes/install.js"; import { registerMcpRoutes } from "./routes/mcp.js"; @@ -138,6 +139,10 @@ export async function buildApp(config: AppConfig): Promise { internalAccessToken: config.NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN, aiWorkspaceRunTokenTtlSeconds: config.NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS, }); + await registerEngineGatewayRoutes(app, { + engineInternalUrl: config.NODEDC_ENGINE_INTERNAL_URL, + publicUrl: config.NODEDC_AGENT_GATEWAY_PUBLIC_URL, + }); await registerToolRoutes(app, { agentsRepository, taskerClient }); await registerMcpRoutes(app, { agentsRepository, taskerClient }); diff --git a/src/config.ts b/src/config.ts index dad9f7e..4b7120d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ const configSchema = z.object({ NODEDC_AI_WORKSPACE_RUN_TOKEN_TTL_SECONDS: z.coerce.number().int().min(300).max(86_400).default(43_200), NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"), NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"), + NODEDC_ENGINE_INTERNAL_URL: z.string().url().default("http://172.22.0.222:3001"), NODEDC_INTERNAL_ACCESS_TOKEN: z.string().min(1).optional(), DATABASE_URL: optionalUrl, }); diff --git a/src/routes/engine.ts b/src/routes/engine.ts new file mode 100644 index 0000000..077a7b1 --- /dev/null +++ b/src/routes/engine.ts @@ -0,0 +1,92 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +type EngineGatewayRouteDeps = { + engineInternalUrl: string; + publicUrl: string; +}; + +const RESPONSE_HEADERS = [ + "cache-control", + "content-disposition", + "mcp-protocol-version", + "mcp-session-id", + "vary", +] as const; + +/** + * Fixed Engine namespace inside the public Agent Gateway. + * + * This is not a generic reverse proxy. Engine keeps ownership of its setup + * codes, device tokens, L1 -> L2 grants, and tool authorization; the public + * gateway only forwards the explicitly registered bootstrap and MCP routes. + */ +export async function registerEngineGatewayRoutes(app: FastifyInstance, deps: EngineGatewayRouteDeps): Promise { + app.get("/engine/healthz", async (request, reply) => + proxyEngine(request, reply, deps, "/health", { binary: false }) + ); + app.get("/engine/install/nodedc-engine-codex-agent-setup.tgz", async (request, reply) => + proxyEngine(request, reply, deps, "/api/engine-agent-api/install/engine-codex-agent.tgz", { binary: true }) + ); + app.post("/engine/api/v1/setup-codes/redeem", async (request, reply) => + proxyEngine(request, reply, deps, "/api/engine-agent-api/setup/redeem") + ); + app.get("/engine/doctor", async (request, reply) => + proxyEngine(request, reply, deps, "/api/engine-agent-api/doctor") + ); + app.get("/engine/mcp", async (request, reply) => + proxyEngine(request, reply, deps, "/api/engine-agent-mcp") + ); + app.post("/engine/mcp", async (request, reply) => + proxyEngine(request, reply, deps, "/api/engine-agent-mcp") + ); +} + +async function proxyEngine( + request: FastifyRequest, + reply: FastifyReply, + deps: EngineGatewayRouteDeps, + pathname: string, + { binary = false }: { binary?: boolean } = {} +): Promise { + const method = request.method.toUpperCase(); + const hasBody = method !== "GET" && method !== "HEAD"; + const publicUrl = new URL(deps.publicUrl); + const headers = new Headers(); + headers.set("Accept", readHeader(request.headers.accept) || (binary ? "application/octet-stream" : "application/json")); + headers.set("X-Forwarded-Host", publicUrl.host); + headers.set("X-Forwarded-Proto", publicUrl.protocol.replace(/:$/, "") || "https"); + + for (const name of ["authorization", "content-type", "mcp-protocol-version", "mcp-session-id", "last-event-id"] as const) { + const value = readHeader(request.headers[name]); + if (value) headers.set(name, value); + } + + let upstream: Response; + try { + upstream = await fetch(new URL(pathname, deps.engineInternalUrl), { + method, + headers, + redirect: "manual", + body: hasBody ? JSON.stringify(request.body ?? {}) : undefined, + signal: AbortSignal.timeout(30_000), + }); + } catch { + return reply.status(502).send({ ok: false, error: "engine_gateway_unavailable" }); + } + + reply.status(upstream.status); + reply.type(upstream.headers.get("content-type") || (binary ? "application/octet-stream" : "application/json; charset=utf-8")); + for (const header of RESPONSE_HEADERS) { + const value = upstream.headers.get(header); + if (value) reply.header(header, value); + } + + if (binary) { + return reply.send(Buffer.from(await upstream.arrayBuffer())); + } + return reply.send(await upstream.text()); +} + +function readHeader(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] || "" : value || ""; +}