feat(foundry): add managed data consumers and agent settings
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
const MAX_BODY_BYTES = 1024 * 1024;
|
||||
|
||||
function sendJson(response, statusCode, payload, headers = {}) {
|
||||
response.writeHead(statusCode, {
|
||||
"cache-control": "no-store",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...headers,
|
||||
});
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) throw new Error("payload_too_large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
if (!chunks.length) return {};
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
function bearerToken(request) {
|
||||
const match = String(request.headers.authorization || "").match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || "";
|
||||
}
|
||||
|
||||
function publicOrigin(request, configuredOrigin) {
|
||||
if (configuredOrigin) return String(configuredOrigin).replace(/\/+$/, "");
|
||||
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
|
||||
const protocol = forwardedProto === "https" ? "https" : "http";
|
||||
const forwardedHost = String(request.headers["x-forwarded-host"] || "").split(",")[0].trim();
|
||||
const host = forwardedHost || String(request.headers.host || "127.0.0.1");
|
||||
return `${protocol}://${host}`.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function actorFromProfile(profile) {
|
||||
const ownerId = String(profile?.id || "").trim();
|
||||
const ownerKey = String(profile?.email || ownerId).trim().toLowerCase();
|
||||
if (!ownerId || !ownerKey) throw new Error("foundry_agent_owner_required");
|
||||
return { ownerId, ownerKey };
|
||||
}
|
||||
|
||||
function errorCode(error) {
|
||||
return String(error?.message || error || "foundry_agent_operation_failed").slice(0, 160);
|
||||
}
|
||||
|
||||
function managementMatch(pathname) {
|
||||
const match = pathname.match(/^\/api\/foundry-agent-api\/agents(?:\/([^/]+)(?:\/(revoke|setup-code))?)?$/);
|
||||
if (!match) return null;
|
||||
return { agentId: match[1] ? decodeURIComponent(match[1]) : null, action: match[2] || null };
|
||||
}
|
||||
|
||||
export function createFoundryAgentGateway({
|
||||
store,
|
||||
configuredPublicOrigin = "",
|
||||
installerPackagePath,
|
||||
ontologyCoreUrl = "",
|
||||
ontologyCoreAccessToken = "",
|
||||
}) {
|
||||
if (!store) throw new Error("foundry_agent_store_required");
|
||||
|
||||
const ontologyUrl = String(ontologyCoreUrl || "").trim().replace(/\/+$/, "");
|
||||
const ontologyToken = String(ontologyCoreAccessToken || "").trim();
|
||||
|
||||
async function handleInstaller(request, response, url) {
|
||||
if (url.pathname !== "/api/foundry-agent/install/nodedc-foundry-codex-agent-setup.tgz") return false;
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { allow: "GET" });
|
||||
response.end();
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const info = await stat(installerPackagePath);
|
||||
response.writeHead(200, {
|
||||
"cache-control": "no-store",
|
||||
"content-disposition": 'attachment; filename="nodedc-foundry-codex-agent-setup.tgz"',
|
||||
"content-length": info.size,
|
||||
"content-type": "application/octet-stream",
|
||||
});
|
||||
createReadStream(installerPackagePath).pipe(response);
|
||||
} catch {
|
||||
sendJson(response, 404, { ok: false, error: "foundry_agent_installer_not_found" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSetupRedeem(request, response, url) {
|
||||
if (url.pathname !== "/api/foundry-agent/v1/setup/redeem") return false;
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, { allow: "POST" });
|
||||
response.end();
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const input = await readJsonBody(request, 16 * 1024);
|
||||
const redeemed = await store.redeemSetupCode(input.code, input.deviceName);
|
||||
const origin = publicOrigin(request, configuredPublicOrigin);
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
agent: redeemed.agent,
|
||||
device: redeemed.device,
|
||||
foundry: {
|
||||
serverName: "nodedc_module_foundry",
|
||||
mcpUrl: `${origin}/api/mcp`,
|
||||
token: redeemed.foundryToken,
|
||||
},
|
||||
ontology: {
|
||||
serverName: "nodedc_ontology",
|
||||
mcpUrl: `${origin}/api/ontology-mcp`,
|
||||
token: redeemed.ontologyToken,
|
||||
readOnly: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
sendJson(response, code === "payload_too_large" ? 413 : 400, { ok: false, error: code });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleDoctor(request, response, url) {
|
||||
if (url.pathname !== "/api/foundry-agent/v1/doctor") return false;
|
||||
if (request.method !== "GET") {
|
||||
response.writeHead(405, { allow: "GET" });
|
||||
response.end();
|
||||
return true;
|
||||
}
|
||||
const session = await store.authenticateFoundryToken(bearerToken(request), { check: true });
|
||||
if (!session) {
|
||||
sendJson(response, 401, { ok: false, error: "foundry_agent_unauthorized" });
|
||||
return true;
|
||||
}
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
agent: session.agent,
|
||||
device: session.device,
|
||||
foundryMcpUrl: `${publicOrigin(request, configuredPublicOrigin)}/api/mcp`,
|
||||
ontologyMcpUrl: `${publicOrigin(request, configuredPublicOrigin)}/api/ontology-mcp`,
|
||||
ontologyReadOnly: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handlePublicRequest(request, response, url) {
|
||||
return await handleInstaller(request, response, url)
|
||||
|| await handleSetupRedeem(request, response, url)
|
||||
|| await handleDoctor(request, response, url);
|
||||
}
|
||||
|
||||
async function handleManagementRequest(request, response, url, profile) {
|
||||
const match = managementMatch(url.pathname);
|
||||
if (!match) return false;
|
||||
const owner = actorFromProfile(profile);
|
||||
try {
|
||||
if (!match.agentId && request.method === "GET") {
|
||||
sendJson(response, 200, { ok: true, agents: await store.listAgents(owner.ownerKey) });
|
||||
return true;
|
||||
}
|
||||
if (!match.agentId && request.method === "POST") {
|
||||
const input = await readJsonBody(request, 640 * 1024);
|
||||
const agent = await store.createAgent({ ...owner, name: input.name, avatarUrl: input.avatarUrl });
|
||||
sendJson(response, 201, { ok: true, agent });
|
||||
return true;
|
||||
}
|
||||
if (match.agentId && !match.action && request.method === "PATCH") {
|
||||
const input = await readJsonBody(request, 640 * 1024);
|
||||
const agent = await store.updateAgent(owner.ownerKey, match.agentId, input);
|
||||
sendJson(response, 200, { ok: true, agent });
|
||||
return true;
|
||||
}
|
||||
if (match.agentId && match.action === "revoke" && request.method === "POST") {
|
||||
const agent = await store.revokeAgent(owner.ownerKey, match.agentId);
|
||||
sendJson(response, 200, { ok: true, agent });
|
||||
return true;
|
||||
}
|
||||
if (match.agentId && match.action === "setup-code" && request.method === "POST") {
|
||||
const setup = await store.issueSetupCode(owner.ownerKey, match.agentId);
|
||||
const origin = publicOrigin(request, configuredPublicOrigin);
|
||||
const packageUrl = `${origin}/api/foundry-agent/install/nodedc-foundry-codex-agent-setup.tgz`;
|
||||
const command = `npm exec --yes --package=${packageUrl} nodedc-foundry-codex-agent -- --server ${origin} --code ${setup.code}`;
|
||||
sendJson(response, 201, { ok: true, install: { command, expiresAt: setup.expiresAt } });
|
||||
return true;
|
||||
}
|
||||
response.writeHead(405, { allow: "GET, POST, PATCH" });
|
||||
response.end();
|
||||
} catch (error) {
|
||||
const code = errorCode(error);
|
||||
const status = code === "foundry_agent_not_found" ? 404 : code === "payload_too_large" ? 413 : 400;
|
||||
sendJson(response, status, { ok: false, error: code });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function authenticateFoundryToken(token, options) {
|
||||
const session = await store.authenticateFoundryToken(token, options);
|
||||
return session?.actor || null;
|
||||
}
|
||||
|
||||
async function handleOntologyMcp(request, response, url) {
|
||||
if (url.pathname !== "/api/ontology-mcp") return false;
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, { allow: "POST" });
|
||||
response.end();
|
||||
return true;
|
||||
}
|
||||
const session = await store.authenticateOntologyToken(bearerToken(request), { check: true });
|
||||
if (!session) {
|
||||
sendJson(response, 401, { ok: false, error: "ontology_agent_unauthorized" });
|
||||
return true;
|
||||
}
|
||||
if (!ontologyUrl || !ontologyToken) {
|
||||
sendJson(response, 503, { ok: false, error: "ontology_agent_proxy_not_configured" });
|
||||
return true;
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = await readJsonBody(request);
|
||||
} catch (error) {
|
||||
sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { ok: false, error: errorCode(error) });
|
||||
return true;
|
||||
}
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await fetch(`${ontologyUrl}/mcp`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
accept: String(request.headers.accept || "application/json, text/event-stream"),
|
||||
authorization: `Bearer ${ontologyToken}`,
|
||||
"content-type": "application/json",
|
||||
"mcp-protocol-version": String(request.headers["mcp-protocol-version"] || MCP_PROTOCOL_VERSION),
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
} catch {
|
||||
sendJson(response, 503, { ok: false, error: "ontology_agent_proxy_unavailable" });
|
||||
return true;
|
||||
}
|
||||
const payload = await upstream.text();
|
||||
response.statusCode = upstream.status;
|
||||
response.setHeader("cache-control", "no-store");
|
||||
response.setHeader("content-type", upstream.headers.get("content-type") || "application/json; charset=utf-8");
|
||||
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) response.setHeader(header, value);
|
||||
}
|
||||
response.end(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
handlePublicRequest,
|
||||
handleManagementRequest,
|
||||
handleOntologyMcp,
|
||||
authenticateFoundryToken,
|
||||
health() {
|
||||
return {
|
||||
configured: Boolean(installerPackagePath),
|
||||
ontologyProxyConfigured: Boolean(ontologyUrl && ontologyToken),
|
||||
tokenLifecycle: "durable-until-revoke",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user