feat(server): expose secure harness API
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ApprovalBroker,
|
||||
AttachmentStore,
|
||||
ModelConnectionStore,
|
||||
ResolvedModelConnection,
|
||||
SessionRecord,
|
||||
SkillCatalog,
|
||||
} from "@agent-studio/harness";
|
||||
import multipart from "@fastify/multipart";
|
||||
import Fastify from "fastify";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { installErrorHandler } from "./errors/error-handler.js";
|
||||
import { registerApprovalRoutes } from "./routes/approvals.js";
|
||||
import { registerAttachmentRoutes } from "./routes/attachments.js";
|
||||
import { registerHealthRoute } from "./routes/health.js";
|
||||
import { registerModelRoutes } from "./routes/models.js";
|
||||
import { registerSessionRoutes } from "./routes/sessions.js";
|
||||
import { registerSkillsRoute } from "./routes/skills.js";
|
||||
|
||||
export type ApiSession = SessionRecord & {
|
||||
startRun(input: { text: string; attachmentIds: string[] }): Promise<{ runId: string }>;
|
||||
cancelRun(reason: "user"): Promise<boolean>;
|
||||
readonly isBusy?: boolean;
|
||||
};
|
||||
|
||||
export type AppDependencies = {
|
||||
sessionRegistry: {
|
||||
create(): Promise<ApiSession>;
|
||||
get(sessionId: string): ApiSession | undefined;
|
||||
delete(sessionId: string): Promise<boolean>;
|
||||
disposeAll(): Promise<void>;
|
||||
};
|
||||
skillCatalog: Pick<SkillCatalog, "list">;
|
||||
modelStore: ModelConnectionStore;
|
||||
attachmentStore: Pick<AttachmentStore, "add" | "remove">;
|
||||
approvalBroker: Pick<ApprovalBroker, "approve" | "deny">;
|
||||
testModelConnection?: (
|
||||
resolved: ResolvedModelConnection,
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ status: "ok" | "deferred"; message: string }>;
|
||||
};
|
||||
|
||||
export function buildApp(dependencies: AppDependencies) {
|
||||
const app = Fastify({
|
||||
logger: false,
|
||||
bodyLimit: 6 * 1024 * 1024,
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
});
|
||||
void app.register(multipart, { limits: { files: 1, fields: 0, fileSize: 5 * 1024 * 1024 } });
|
||||
installErrorHandler(app);
|
||||
registerHealthRoute(app);
|
||||
registerSkillsRoute(app, dependencies);
|
||||
registerSessionRoutes(app, dependencies);
|
||||
registerModelRoutes(app, dependencies);
|
||||
registerAttachmentRoutes(app, dependencies);
|
||||
registerApprovalRoutes(app, dependencies);
|
||||
app.addHook("onClose", async () => dependencies.sessionRegistry.disposeAll());
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvironmentSchema = z.object({
|
||||
HOST: z.string().default("127.0.0.1"),
|
||||
PORT: z.coerce.number().int().min(1).max(65_535).default(3_001),
|
||||
SESSIONS_ROOT: z.string().min(1).default(".agent-studio-sessions"),
|
||||
AGENT_DIR: z.string().min(1).default(".agent-studio-runtime"),
|
||||
MAX_IMAGE_BYTES: z.coerce.number().int().positive().default(5 * 1024 * 1024),
|
||||
SESSION_RUN_TIMEOUT_MS: z.coerce.number().int().positive().default(120_000),
|
||||
APPROVAL_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000),
|
||||
MAX_IMAGES_PER_MESSAGE: z.coerce.number().int().positive().max(16).default(4),
|
||||
ALLOW_PRIVATE_LLM_BASE_URLS: z.enum(["true", "false"]).default("false"),
|
||||
}).passthrough();
|
||||
|
||||
export function loadServerConfig(environment: NodeJS.ProcessEnv) {
|
||||
const parsed = EnvironmentSchema.parse(environment);
|
||||
return {
|
||||
host: parsed.HOST,
|
||||
port: parsed.PORT,
|
||||
sessionsRoot: parsed.SESSIONS_ROOT,
|
||||
agentDir: parsed.AGENT_DIR,
|
||||
maxImageBytes: parsed.MAX_IMAGE_BYTES,
|
||||
runTimeoutMs: parsed.SESSION_RUN_TIMEOUT_MS,
|
||||
approvalTimeoutMs: parsed.APPROVAL_TIMEOUT_MS,
|
||||
maxImagesPerMessage: parsed.MAX_IMAGES_PER_MESSAGE,
|
||||
allowPrivateLlmBaseUrls: parsed.ALLOW_PRIVATE_LLM_BASE_URLS === "true",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import { createApiError } from "@agent-studio/shared";
|
||||
import type { FastifyError, FastifyInstance } from "fastify";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function installErrorHandler(app: FastifyInstance): void {
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
void reply.status(404).send(createApiError("ROUTE_NOT_FOUND", "Route not found", request.id));
|
||||
});
|
||||
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
|
||||
const mapped = mapError(error);
|
||||
void reply.status(mapped.statusCode).send(
|
||||
createApiError(mapped.code, mapped.message, request.id, mapped.details),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function mapError(error: FastifyError | Error): {
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
} {
|
||||
if (error instanceof ZodError) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Request validation failed",
|
||||
details: { fields: error.flatten().fieldErrors },
|
||||
};
|
||||
}
|
||||
if (error instanceof HarnessError) {
|
||||
return {
|
||||
statusCode: statusForHarnessCode(error.code),
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: safeDetails(error.details),
|
||||
};
|
||||
}
|
||||
if ("statusCode" in error && error.statusCode === 413) {
|
||||
return { statusCode: 413, code: "PAYLOAD_TOO_LARGE", message: "Request payload is too large", details: {} };
|
||||
}
|
||||
return { statusCode: 500, code: "INTERNAL_ERROR", message: "Internal server error", details: {} };
|
||||
}
|
||||
|
||||
function statusForHarnessCode(code: string): number {
|
||||
if (code.includes("NOT_FOUND") || code === "SESSION_NOT_FOUND") return 404;
|
||||
if (code === "SESSION_BUSY" || code.includes("ALREADY_RESOLVED") || code.includes("DUPLICATE")) return 409;
|
||||
if (code === "IMAGE_TOO_LARGE") return 413;
|
||||
if (code === "MODEL_DOES_NOT_SUPPORT_IMAGES") return 422;
|
||||
return 400;
|
||||
}
|
||||
|
||||
function safeDetails(details: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(details).filter(([key]) => !/key|token|secret|authorization|credential/i.test(key)),
|
||||
);
|
||||
}
|
||||
+134
-1
@@ -1 +1,134 @@
|
||||
export {};
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
FileAttachmentStore,
|
||||
HarnessSession,
|
||||
InMemoryApprovalBroker,
|
||||
InMemoryEventStore,
|
||||
InMemoryModelConnectionStore,
|
||||
InMemorySessionRegistry,
|
||||
SafeWorkspace,
|
||||
SkillCatalog,
|
||||
SkillEventInference,
|
||||
createMockAdsMetricsTool,
|
||||
createPiAgentSessionPortFactory,
|
||||
createSaveReportDraftTool,
|
||||
validateBaseUrl,
|
||||
} from "@agent-studio/harness";
|
||||
import type { ModelConnectionInput } from "@agent-studio/shared";
|
||||
import { buildApp } from "./app.js";
|
||||
import { loadServerConfig } from "./config.js";
|
||||
|
||||
export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.env) {
|
||||
const config = loadServerConfig(environment);
|
||||
const projectRoot = process.cwd();
|
||||
const sessionsRoot = resolve(projectRoot, config.sessionsRoot);
|
||||
const agentDir = resolve(projectRoot, config.agentDir);
|
||||
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
||||
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const eventStore = new InMemoryEventStore();
|
||||
const skillCatalog = new SkillCatalog({
|
||||
cwd: projectRoot,
|
||||
agentDir,
|
||||
...(environment.AGENT_SKILLS_DIRS === undefined
|
||||
? {}
|
||||
: { extraDirsValue: environment.AGENT_SKILLS_DIRS }),
|
||||
});
|
||||
await skillCatalog.reload();
|
||||
const environmentConnection = await readEnvironmentConnection(environment, config.allowPrivateLlmBaseUrls);
|
||||
const modelStore = new InMemoryModelConnectionStore({
|
||||
...(environmentConnection === undefined ? {} : { environment: environmentConnection }),
|
||||
validateUrl: (url) => validateBaseUrl(url, {
|
||||
allowPrivateNetwork: config.allowPrivateLlmBaseUrls,
|
||||
allowLocalhostHttp: environment.NODE_ENV !== "production",
|
||||
}),
|
||||
});
|
||||
const approvalBroker = new InMemoryApprovalBroker({
|
||||
eventStore,
|
||||
timeoutMs: config.approvalTimeoutMs,
|
||||
});
|
||||
const attachmentStore = new FileAttachmentStore({
|
||||
sessionsRoot,
|
||||
eventStore,
|
||||
maxBytes: config.maxImageBytes,
|
||||
maxAttachmentsPerMessage: config.maxImagesPerMessage,
|
||||
});
|
||||
const registry = new InMemorySessionRegistry<HarnessSession>({
|
||||
sessionsRoot,
|
||||
createSession: ({ sessionId, workspaceRoot }) => {
|
||||
eventStore.append(sessionId, { type: "session.started", payload: { cwd: "." } });
|
||||
return new HarnessSession({
|
||||
sessionId,
|
||||
workspaceRoot,
|
||||
eventStore,
|
||||
modelStore,
|
||||
attachmentStore,
|
||||
approvalBroker,
|
||||
runTimeoutMs: config.runTimeoutMs,
|
||||
createSkillInference: (runId) => new SkillEventInference({
|
||||
sessionId,
|
||||
runId,
|
||||
eventStore,
|
||||
skillIndex: skillCatalog.index(),
|
||||
}),
|
||||
createAgentPort: async (input) => {
|
||||
const workspace = await SafeWorkspace.create(workspaceRoot);
|
||||
return createPiAgentSessionPortFactory({
|
||||
agentDir,
|
||||
approvalBroker,
|
||||
customTools: [
|
||||
createMockAdsMetricsTool(join(projectRoot, "fixtures", "ads-account.json")),
|
||||
createSaveReportDraftTool(workspace),
|
||||
],
|
||||
})(input);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return buildApp({
|
||||
sessionRegistry: registry,
|
||||
skillCatalog,
|
||||
modelStore,
|
||||
attachmentStore,
|
||||
approvalBroker,
|
||||
});
|
||||
}
|
||||
|
||||
async function readEnvironmentConnection(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
allowPrivateNetwork: boolean,
|
||||
): Promise<ModelConnectionInput | undefined> {
|
||||
const providerId = environment.AGENT_PROVIDER?.trim();
|
||||
const modelId = environment.AGENT_MODEL?.trim();
|
||||
if (providerId === undefined || providerId === "" || modelId === undefined || modelId === "") return undefined;
|
||||
const api = environment.LLM_API ?? "openai-responses";
|
||||
if (api !== "openai-responses" && api !== "openai-completions" && api !== "anthropic-messages") return undefined;
|
||||
const rawBaseUrl = environment.LLM_BASE_URL?.trim();
|
||||
const baseUrl = rawBaseUrl === undefined || rawBaseUrl === ""
|
||||
? undefined
|
||||
: (await validateBaseUrl(rawBaseUrl, {
|
||||
allowPrivateNetwork,
|
||||
allowLocalhostHttp: environment.NODE_ENV !== "production",
|
||||
})).normalizedUrl;
|
||||
const apiKey = providerId === "anthropic"
|
||||
? environment.ANTHROPIC_API_KEY?.trim()
|
||||
: environment.OPENAI_API_KEY?.trim();
|
||||
return {
|
||||
providerId,
|
||||
modelId,
|
||||
api,
|
||||
supportsImages: environment.AGENT_MODEL_SUPPORTS_IMAGES !== "false",
|
||||
...(baseUrl === undefined ? {} : { baseUrl }),
|
||||
...(apiKey === undefined || apiKey === "" ? {} : { apiKey }),
|
||||
};
|
||||
}
|
||||
|
||||
const entryPoint = process.argv[1];
|
||||
if (entryPoint !== undefined && import.meta.url === pathToFileURL(entryPoint).href) {
|
||||
const config = loadServerConfig(process.env);
|
||||
const app = await buildDefaultApp();
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
|
||||
const Params = z.object({ approvalId: z.string().regex(/^ap_[a-zA-Z0-9_-]+$/) }).strict();
|
||||
|
||||
export function registerApprovalRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
app.post("/api/approvals/:approvalId/approve", (request) => {
|
||||
const { approvalId } = Params.parse(request.params);
|
||||
return { approval: dependencies.approvalBroker.approve(approvalId), requestId: request.id };
|
||||
});
|
||||
app.post("/api/approvals/:approvalId/deny", (request) => {
|
||||
const { approvalId } = Params.parse(request.params);
|
||||
return { approval: dependencies.approvalBroker.deny(approvalId), requestId: request.id };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { HarnessError, type SupportedImageMediaType } from "@agent-studio/harness";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
const Params = z.object({ sessionId: z.string().regex(/^ses_[a-zA-Z0-9_-]+$/) }).strict();
|
||||
const DeleteParams = Params.extend({ attachmentId: z.string().regex(/^att_[a-zA-Z0-9_-]+$/) }).strict();
|
||||
const mediaTypes = new Set<SupportedImageMediaType>(["image/jpeg", "image/png", "image/webp"]);
|
||||
|
||||
export function registerAttachmentRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
app.post("/api/sessions/:sessionId/attachments", async (request, reply) => {
|
||||
const { sessionId } = Params.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
if (!request.isMultipart()) throw new HarnessError("MULTIPART_REQUIRED", "Expected multipart/form-data");
|
||||
let attachment: Awaited<ReturnType<AppDependencies["attachmentStore"]["add"]>> | undefined;
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type !== "file" || attachment !== undefined) {
|
||||
if (part.type === "file") part.file.resume();
|
||||
if (attachment !== undefined) await dependencies.attachmentStore.remove(sessionId, attachment.attachmentId);
|
||||
throw new HarnessError("INVALID_MULTIPART", "Exactly one image file is required");
|
||||
}
|
||||
if (!mediaTypes.has(part.mimetype as SupportedImageMediaType)) {
|
||||
part.file.resume();
|
||||
throw new HarnessError("IMAGE_TYPE_NOT_ALLOWED", "Only JPEG, PNG, and WebP images are allowed");
|
||||
}
|
||||
attachment = await dependencies.attachmentStore.add(sessionId, {
|
||||
name: part.filename,
|
||||
mediaType: part.mimetype as SupportedImageMediaType,
|
||||
stream: part.file,
|
||||
});
|
||||
}
|
||||
if (attachment === undefined) throw new HarnessError("INVALID_MULTIPART", "Exactly one image file is required");
|
||||
return reply.status(201).send({ attachment, requestId: request.id });
|
||||
});
|
||||
|
||||
app.delete("/api/sessions/:sessionId/attachments/:attachmentId", async (request) => {
|
||||
const { sessionId, attachmentId } = DeleteParams.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
const removed = await dependencies.attachmentStore.remove(sessionId, attachmentId);
|
||||
if (!removed) throw new HarnessError("ATTACHMENT_NOT_FOUND", "Attachment not found");
|
||||
return { removed: true, requestId: request.id };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export function registerHealthRoute(app: FastifyInstance): void {
|
||||
app.get("/health", (request) => ({ status: "ok", requestId: request.id }));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import { ModelConnectionInputSchema } from "@agent-studio/shared";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
import { requireSession } from "./sessions.js";
|
||||
|
||||
const Params = z.object({ sessionId: z.string().regex(/^ses_[a-zA-Z0-9_-]+$/) }).strict();
|
||||
|
||||
export function registerModelRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
app.get("/api/sessions/:sessionId/model-config", (request) => {
|
||||
const { sessionId } = Params.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
const modelConfig = dependencies.modelStore.getRedacted(sessionId) ?? { configured: false, source: "missing" };
|
||||
return { modelConfig, requestId: request.id };
|
||||
});
|
||||
|
||||
app.put("/api/sessions/:sessionId/model-config", async (request) => {
|
||||
const { sessionId } = Params.parse(request.params);
|
||||
const session = requireSession(dependencies, sessionId);
|
||||
if (session.isBusy === true) {
|
||||
throw new HarnessError("SESSION_BUSY", "Model configuration cannot change during a run");
|
||||
}
|
||||
const input = ModelConnectionInputSchema.parse(request.body);
|
||||
await dependencies.modelStore.configure(sessionId, input);
|
||||
return { modelConfig: dependencies.modelStore.getRedacted(sessionId), requestId: request.id };
|
||||
});
|
||||
|
||||
app.delete("/api/sessions/:sessionId/model-config/credentials", (request) => {
|
||||
const { sessionId } = Params.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
dependencies.modelStore.clearCredentials(sessionId);
|
||||
return { cleared: true, requestId: request.id };
|
||||
});
|
||||
|
||||
app.post("/api/sessions/:sessionId/model-config/test", async (request) => {
|
||||
const { sessionId } = Params.parse(request.params);
|
||||
requireSession(dependencies, sessionId);
|
||||
const resolved = dependencies.modelStore.resolveConnection(sessionId);
|
||||
const result = dependencies.testModelConnection === undefined
|
||||
? { status: "deferred" as const, message: "Saved; validation will occur on first run." }
|
||||
: await dependencies.testModelConnection(resolved, AbortSignal.timeout(5_000));
|
||||
return { ...result, requestId: request.id };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import { MessageInputSchema } from "@agent-studio/shared";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
|
||||
const SessionParams = z.object({ sessionId: z.string().regex(/^ses_[a-zA-Z0-9_-]+$/) }).strict();
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
app.post("/api/sessions", async (request, reply) => {
|
||||
const session = await dependencies.sessionRegistry.create();
|
||||
return reply.status(201).send({ sessionId: session.sessionId, requestId: request.id });
|
||||
});
|
||||
|
||||
app.delete("/api/sessions/:sessionId", async (request) => {
|
||||
const { sessionId } = SessionParams.parse(request.params);
|
||||
const deleted = await dependencies.sessionRegistry.delete(sessionId);
|
||||
if (!deleted) throw new HarnessError("SESSION_NOT_FOUND", "Session not found");
|
||||
return { deleted: true, requestId: request.id };
|
||||
});
|
||||
|
||||
app.post("/api/sessions/:sessionId/messages", async (request, reply) => {
|
||||
const { sessionId } = SessionParams.parse(request.params);
|
||||
const session = requireSession(dependencies, sessionId);
|
||||
const message = MessageInputSchema.parse(request.body);
|
||||
const result = await session.startRun(message);
|
||||
return reply.status(202).send({ ...result, requestId: request.id });
|
||||
});
|
||||
|
||||
app.post("/api/sessions/:sessionId/cancel", async (request, reply) => {
|
||||
const { sessionId } = SessionParams.parse(request.params);
|
||||
const session = requireSession(dependencies, sessionId);
|
||||
const cancelled = await session.cancelRun("user");
|
||||
return reply.status(cancelled ? 202 : 200).send({ cancelled, requestId: request.id });
|
||||
});
|
||||
}
|
||||
|
||||
export function requireSession(dependencies: AppDependencies, sessionId: string) {
|
||||
const session = dependencies.sessionRegistry.get(sessionId);
|
||||
if (session === undefined) throw new HarnessError("SESSION_NOT_FOUND", "Session not found");
|
||||
return session;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppDependencies } from "../app.js";
|
||||
|
||||
export function registerSkillsRoute(app: FastifyInstance, dependencies: AppDependencies): void {
|
||||
app.get("/api/skills", (request) => ({
|
||||
skills: dependencies.skillCatalog.list(),
|
||||
requestId: request.id,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user