feat: initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@agent-studio/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": { "predev": "pnpm --filter @agent-studio/shared build && pnpm --filter @agent-studio/harness build", "dev": "tsx watch src/main.ts", "prestart": "pnpm --filter @agent-studio/shared build && pnpm --filter @agent-studio/harness build", "start": "node dist/main.js", "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.json" },
|
||||
"dependencies": {
|
||||
"@agent-studio/harness": "workspace:*",
|
||||
"@agent-studio/shared": "workspace:*",
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/static": "^8.3.0",
|
||||
"fastify": "^5.6.2",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": { "@types/node": "^24.10.1", "typescript": "^5.9.3" }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import multipart from "@fastify/multipart";
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { ZodError, z } from "zod";
|
||||
import {
|
||||
FileAttachmentStore,
|
||||
EncryptedFileCredentialVault,
|
||||
HarnessError,
|
||||
InMemoryEventStore,
|
||||
InMemoryModelConnectionStore,
|
||||
InMemorySessionRegistry,
|
||||
PiAgentSessionFactory,
|
||||
SkillCatalog,
|
||||
safeErrorMessage,
|
||||
type AgentSessionFactory,
|
||||
} from "@agent-studio/harness";
|
||||
import { modelConnectionInputSchema, sendMessageSchema } from "@agent-studio/shared";
|
||||
|
||||
const idParams = z.object({ sessionId: z.string().min(1) });
|
||||
const attachmentParams = idParams.extend({ attachmentId: z.string().min(1) });
|
||||
const approvalParams = z.object({ approvalId: z.string().min(1) });
|
||||
const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative().optional() });
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, value: unknown): T { return schema.parse(value); }
|
||||
|
||||
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog }
|
||||
export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void }
|
||||
|
||||
export async function buildApp(options: AppOptions = {}): Promise<FastifyInstance> {
|
||||
const projectRoot = options.projectRoot ?? path.resolve(import.meta.dirname, "../../..");
|
||||
const sessionsRoot = path.join(projectRoot, "workspace", "sessions");
|
||||
const agentDir = path.join(projectRoot, "workspace", ".pi-catalog");
|
||||
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
||||
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const events = new InMemoryEventStore(1_000);
|
||||
const credentialVault = process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY
|
||||
? new EncryptedFileCredentialVault(path.join(projectRoot, "workspace", "model-credentials.enc.json"), process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY)
|
||||
: undefined;
|
||||
const models = new InMemoryModelConnectionStore(process.env, credentialVault);
|
||||
// Validate and cache the .env default before any Web Session is accepted.
|
||||
await models.initialize();
|
||||
const attachments = new FileAttachmentStore(sessionsRoot, Number(process.env.MAX_IMAGE_BYTES ?? 5 * 1024 * 1024));
|
||||
const skills = new SkillCatalog(projectRoot, agentDir);
|
||||
await skills.reload();
|
||||
const sessions = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, skills, undefined, options.approvalTimeoutMs ?? Number(process.env.APPROVAL_TIMEOUT_MS ?? 60_000), options.runTimeoutMs ?? Number(process.env.SESSION_RUN_TIMEOUT_MS ?? 120_000));
|
||||
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals));
|
||||
options.onReady?.({ sessions, events, attachments, models, skills });
|
||||
|
||||
const app = Fastify({
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
logger: process.env.NODE_ENV === "test" ? false : { level: process.env.LOG_LEVEL ?? "info", redact: { paths: ["req.headers.authorization", "req.body.apiKey", "*.apiKey", "*.authorization"], censor: "[REDACTED]" } },
|
||||
bodyLimit: 1_000_000,
|
||||
});
|
||||
await app.register(multipart, { limits: { files: 1, fileSize: Number(process.env.MAX_IMAGE_BYTES ?? 5 * 1024 * 1024) + 1, fields: 4 } });
|
||||
app.addHook("onRequest", async (request, reply) => { reply.header("x-request-id", request.id); });
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof ZodError) return sendError(reply, request, 400, "VALIDATION_ERROR", "Request validation failed", { fields: z.flattenError(error).fieldErrors });
|
||||
if (error instanceof HarnessError) return sendError(reply, request, error.statusCode, error.code, error.message, error.details);
|
||||
if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") return sendError(reply, request, 413, "ATTACHMENT_TOO_LARGE", "Image exceeds the configured size limit");
|
||||
request.log.error({ requestId: request.id, code: "INTERNAL_ERROR", message: safeErrorMessage(error) }, "request failed");
|
||||
return sendError(reply, request, 500, "INTERNAL_ERROR", "Internal server error");
|
||||
});
|
||||
|
||||
app.get("/health", async (request) => ({ ok: true, requestId: request.id }));
|
||||
app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id }));
|
||||
app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); });
|
||||
app.delete("/api/sessions/:sessionId", async (request, reply) => { const { sessionId } = parse(idParams, request.params); await sessions.delete(sessionId); return reply.send({ deleted: true, requestId: request.id }); });
|
||||
app.post("/api/sessions/:sessionId/messages", async (request, reply) => { const { sessionId } = parse(idParams, request.params); const body = parse(sendMessageSchema, request.body); const result = await sessions.send(sessionId, body); return reply.code(202).send({ ...result, requestId: request.id }); });
|
||||
app.post("/api/sessions/:sessionId/cancel", async (request, reply) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); await sessions.cancel(sessionId); return reply.send({ cancelled: true, requestId: request.id }); });
|
||||
|
||||
app.get("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return { ...models.getRedacted(sessionId), requestId: request.id }; });
|
||||
app.put("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = parse(idParams, request.params); const input = parse(modelConnectionInputSchema, request.body); return { ...(await sessions.configureModel(sessionId, input)), requestId: request.id }; });
|
||||
app.delete("/api/sessions/:sessionId/model-config/credentials", async (request) => { const { sessionId } = parse(idParams, request.params); await sessions.clearCredentials(sessionId); return { cleared: true, requestId: request.id }; });
|
||||
app.post("/api/sessions/:sessionId/model-config/test", async (request) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); const config = models.getRedacted(sessionId); if (!config.configured) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422); return { status: "deferred", message: "配置已通过本地校验;为避免记录模型正文,将在首次运行时验证连接。", requestId: request.id }; });
|
||||
|
||||
app.post("/api/sessions/:sessionId/attachments", async (request, reply) => {
|
||||
const { sessionId } = parse(idParams, request.params);
|
||||
if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404);
|
||||
const file = await request.file();
|
||||
if (!file) throw new HarnessError("ATTACHMENT_REQUIRED", "Multipart image file is required", 400);
|
||||
try {
|
||||
const item = await attachments.add(sessionId, { filename: file.filename, mimetype: file.mimetype, stream: file.file });
|
||||
events.append(sessionId, { type: "attachment.uploaded", payload: { attachmentId: item.attachmentId, name: item.name, mediaType: item.mediaType, byteSize: item.byteSize, width: item.width, height: item.height, status: "ready" } });
|
||||
return reply.code(201).send({ ...item, requestId: request.id });
|
||||
} catch (error) {
|
||||
events.append(sessionId, { type: "attachment.rejected", payload: { name: file.filename.slice(0, 200), code: error instanceof HarnessError ? error.code : "ATTACHMENT_REJECTED", message: error instanceof HarnessError ? error.message : "Attachment rejected" } });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
app.delete("/api/sessions/:sessionId/attachments/:attachmentId", async (request) => { const { sessionId, attachmentId } = parse(attachmentParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); await attachments.remove(sessionId, attachmentId); return { deleted: true, requestId: request.id }; });
|
||||
|
||||
app.post("/api/approvals/:approvalId/approve", async (request) => { const { approvalId } = parse(approvalParams, request.params); const result = sessions.approvals.approve(approvalId); return { approval: result.request, requestId: request.id }; });
|
||||
app.post("/api/approvals/:approvalId/deny", async (request) => { const { approvalId } = parse(approvalParams, request.params); const result = sessions.approvals.deny(approvalId); return { approval: result.request, requestId: request.id }; });
|
||||
|
||||
app.get("/api/sessions/:sessionId/events", async (request, reply) => {
|
||||
const { sessionId } = parse(idParams, request.params);
|
||||
if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404);
|
||||
const query = parse(eventQuery, request.query);
|
||||
const lastEventId = request.headers["last-event-id"];
|
||||
const cursor = typeof lastEventId === "string" ? { eventId: lastEventId } : query.afterSequence !== undefined ? { sequence: query.afterSequence } : {};
|
||||
reply.hijack();
|
||||
const raw = reply.raw;
|
||||
raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no", "x-request-id": request.id });
|
||||
const write = (chunk: string) => { if (raw.writableLength > 1024 * 1024) { raw.destroy(); return; } raw.write(chunk); };
|
||||
const batch = events.listAfter(sessionId, cursor);
|
||||
if (batch.gap) write(`event: stream.gap\ndata: ${JSON.stringify({ type: "stream.gap", ...batch.gap })}\n\n`);
|
||||
for (const item of batch.events) write(serializeEvent(item));
|
||||
const unsubscribe = events.subscribe(sessionId, (item) => write(serializeEvent(item)));
|
||||
const heartbeat = setInterval(() => write(`: heartbeat ${Date.now()}\n\n`), options.heartbeatMs ?? 15_000);
|
||||
const cleanup = () => { clearInterval(heartbeat); unsubscribe(); };
|
||||
request.raw.once("close", cleanup);
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => { await sessions.shutdown(); });
|
||||
return app;
|
||||
}
|
||||
|
||||
function serializeEvent(event: { eventId: string; type: string } & Record<string, unknown>): string { return `id: ${event.eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; }
|
||||
function sendError(reply: FastifyReply, request: FastifyRequest, status: number, code: string, message: string, details: Record<string, unknown> = {}) { return reply.code(status).send({ error: { code, message, requestId: request.id, details } }); }
|
||||
@@ -0,0 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { loadEnvFile } from "node:process";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
try { loadEnvFile(path.resolve(import.meta.dirname, "../../../.env")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
const app = await buildApp();
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
await app.listen({ host: "127.0.0.1", port });
|
||||
|
||||
const shutdown = async () => { await app.close(); process.exit(0); };
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mkdtemp, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildApp, type AppServices } from "../src/app.js";
|
||||
import type { AgentSessionFactory, AgentSessionPort } from "@agent-studio/harness";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
class ServerFakePort implements AgentSessionPort {
|
||||
readonly modelId = "fake-model"; readonly supportsImages = true; disposed = false; private finish?: () => void;
|
||||
prompt(): Promise<void> { return new Promise((resolve) => { this.finish = resolve; }); }
|
||||
async abort(): Promise<void> { this.finish?.(); }
|
||||
subscribe(): () => void { return () => undefined; }
|
||||
dispose(): void { this.disposed = true; }
|
||||
complete(): void { this.finish?.(); }
|
||||
}
|
||||
class ServerFakeFactory implements AgentSessionFactory { port = new ServerFakePort(); async create() { return this.port; } }
|
||||
const apps: FastifyInstance[] = [];
|
||||
afterEach(async () => { while (apps.length) await apps.pop()?.close(); });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "studio-server-")); await mkdir(path.join(root, ".agents/skills"), { recursive: true }); await mkdir(path.join(root, ".pi/skills"), { recursive: true }); await mkdir(path.join(root, "fixtures"));
|
||||
const factory = new ServerFakeFactory(); let services: AppServices | undefined; const app = await buildApp({ projectRoot: root, agentFactory: factory, heartbeatMs: 20, onReady: (value) => { services = value; } }); apps.push(app); return { app, factory, services: services! };
|
||||
}
|
||||
async function createSession(app: FastifyInstance) { const response = await app.inject({ method: "POST", url: "/api/sessions" }); return (response.json() as { sessionId: string }).sessionId; }
|
||||
async function configure(app: FastifyInstance, sessionId: string, supportsImages = true) { return app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake-model", apiKey: "sk-test-never-return", supportsImages } }); }
|
||||
|
||||
describe("Fastify API", () => {
|
||||
it("returns health and a request id", async () => { const { app } = await fixture(); const response = await app.inject({ method: "GET", url: "/health" }); expect(response.statusCode).toBe(200); expect(response.headers["x-request-id"]).toMatch(/^req_/); expect(response.json()).toMatchObject({ ok: true }); });
|
||||
it("uses the unified 404 error envelope", async () => { const { app } = await fixture(); const response = await app.inject({ method: "POST", url: "/api/sessions/missing/cancel" }); expect(response.statusCode).toBe(404); expect(response.json()).toMatchObject({ error: { code: "SESSION_NOT_FOUND", message: "Session not found" } }); });
|
||||
it("returns safe Zod field errors", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "", attachmentIds: [] } }); expect(response.statusCode).toBe(400); expect(response.json()).toHaveProperty("error.details.fields"); });
|
||||
it("never returns the API key from model config", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const saved = await configure(app, sessionId); expect(saved.statusCode).toBe(200); expect(saved.body).not.toContain("sk-test-never-return"); const read = await app.inject({ method: "GET", url: `/api/sessions/${sessionId}/model-config` }); expect(read.body).not.toContain("sk-test-never-return"); expect(read.json()).toMatchObject({ configured: true, keyConfigured: true, keyHint: "••••turn" }); });
|
||||
it("rejects SSRF base URLs", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const response = await app.inject({ method: "PUT", url: `/api/sessions/${sessionId}/model-config`, payload: { providerId: "fake", api: "openai-responses", modelId: "fake", apiKey: "x", supportsImages: false, baseUrl: "https://169.254.169.254/latest" } }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ error: { code: "SSRF_BLOCKED" } }); });
|
||||
it("returns 202 without waiting for the Agent run", async () => { const { app, factory } = await fixture(); const sessionId = await createSession(app); await configure(app, sessionId); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "hello", attachmentIds: [] } }); expect(response.statusCode).toBe(202); expect(response.json()).toHaveProperty("runId"); factory.port.complete(); });
|
||||
it("returns 409 for concurrent messages", async () => { const { app, factory } = await fixture(); const sessionId = await createSession(app); await configure(app, sessionId); await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "one", attachmentIds: [] } }); const response = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/messages`, payload: { text: "two", attachmentIds: [] } }); expect(response.statusCode).toBe(409); expect(response.json()).toMatchObject({ error: { code: "SESSION_BUSY" } }); factory.port.complete(); });
|
||||
it("supports approve and deny endpoints with conflict detection", async () => { const { app, services } = await fixture(); const sessionId = await createSession(app); const controller = new AbortController(); const pending = services.sessions.approvals.request({ sessionId, runId: "run_test", toolCallId: "tool_test", toolName: "save_report_draft", arguments: {}, riskLevel: "medium" }, controller.signal); const event = services.events.listAfter(sessionId).events.find((item) => item.type === "approval.required"); if (!event || event.type !== "approval.required") throw new Error("missing approval"); const approved = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/approve` }); expect(approved.statusCode).toBe(200); expect((await pending).status).toBe("approved"); const conflict = await app.inject({ method: "POST", url: `/api/approvals/${event.payload.approvalId}/deny` }); expect(conflict.statusCode).toBe(409); });
|
||||
it("accepts a valid multipart PNG and rejects SVG", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"); const accepted = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "image.png", "image/png", png) }); expect(accepted.statusCode).toBe(201); expect(accepted.json()).toMatchObject({ mediaType: "image/png", width: 1, status: "ready" }); const rejected = await app.inject({ method: "POST", url: `/api/sessions/${sessionId}/attachments`, headers: { "content-type": "multipart/form-data; boundary=studio" }, payload: multipart("studio", "x.svg", "image/svg+xml", Buffer.from("<svg/>")) }); expect(rejected.statusCode).toBe(415); });
|
||||
it("serves SSE events and heartbeat comments", async () => { const { app } = await fixture(); const sessionId = await createSession(app); const address = await app.listen({ host: "127.0.0.1", port: 0 }); const controller = new AbortController(); const response = await fetch(`${address}/api/sessions/${sessionId}/events`, { signal: controller.signal }); expect(response.headers.get("content-type")).toContain("text/event-stream"); const reader = response.body!.getReader(); let text = ""; for (let count = 0; count < 4 && !text.includes(": heartbeat"); count++) { const chunk = await reader.read(); text += new TextDecoder().decode(chunk.value); } controller.abort(); expect(text).toContain("event: session.started"); expect(text).toContain(": heartbeat"); });
|
||||
});
|
||||
|
||||
function multipart(boundary: string, filename: string, mime: string, data: Buffer): Buffer { return Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]); }
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "dist", "tsBuildInfoFile": "dist/.tsbuildinfo" },
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user