feat(server): expose secure harness API
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --no-error-on-unmatched-pattern --max-warnings 0",
|
||||
"pretest": "pnpm --filter @agent-studio/shared build && pnpm --filter @agent-studio/harness build",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestApp, makeSessionBusy } from "./test-app.js";
|
||||
|
||||
const apps: Array<ReturnType<typeof createTestApp>["app"]> = [];
|
||||
afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())));
|
||||
|
||||
describe("HTTP API", () => {
|
||||
it("serves health, skills, and creates a session with request IDs", async () => {
|
||||
const { app } = createTestApp();
|
||||
apps.push(app);
|
||||
const health = await app.inject({ method: "GET", url: "/health" });
|
||||
const skills = await app.inject({ method: "GET", url: "/api/skills" });
|
||||
const created = await app.inject({ method: "POST", url: "/api/sessions" });
|
||||
|
||||
const healthBody = health.json<{ status: string; requestId: string }>();
|
||||
const skillsBody = skills.json<{ skills: Array<{ name: string }>; requestId: string }>();
|
||||
const createdBody = created.json<{ sessionId: string; requestId: string }>();
|
||||
expect(health.statusCode).toBe(200);
|
||||
expect(healthBody.status).toBe("ok");
|
||||
expect(healthBody.requestId).toMatch(/^req_/);
|
||||
expect(skillsBody.skills[0]?.name).toBe("ads-analysis");
|
||||
expect(skillsBody.requestId).toMatch(/^req_/);
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(createdBody.sessionId).toBe("ses_1");
|
||||
expect(createdBody.requestId).toMatch(/^req_/);
|
||||
});
|
||||
|
||||
it("accepts a run without waiting for completion", async () => {
|
||||
const { app, session } = createTestApp();
|
||||
apps.push(app);
|
||||
const response = await app.inject({ method: "POST", url: "/api/sessions/ses_1/messages", payload: { text: "hello", attachmentIds: [] } });
|
||||
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json<{ runId: string; requestId: string }>();
|
||||
expect(body.runId).toBe("run_1");
|
||||
expect(body.requestId).toMatch(/^req_/);
|
||||
expect(session.startRun).toHaveBeenCalledWith({ text: "hello", attachmentIds: [] });
|
||||
});
|
||||
|
||||
it("returns safe validation, not-found, and busy errors", async () => {
|
||||
const { app, session } = createTestApp();
|
||||
apps.push(app);
|
||||
const invalid = await app.inject({ method: "POST", url: "/api/sessions/ses_1/messages", payload: { text: "", attachmentIds: [] } });
|
||||
const missing = await app.inject({ method: "POST", url: "/api/sessions/ses_missing/cancel" });
|
||||
makeSessionBusy(session);
|
||||
const busy = await app.inject({ method: "POST", url: "/api/sessions/ses_1/messages", payload: { text: "again" } });
|
||||
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(invalid.json<{ error: { code: string; requestId: string } }>().error).toMatchObject({ code: "VALIDATION_ERROR" });
|
||||
expect(invalid.json<{ error: { requestId: string } }>().error.requestId).toMatch(/^req_/);
|
||||
expect(missing.statusCode).toBe(404);
|
||||
expect(missing.json()).toMatchObject({ error: { code: "SESSION_NOT_FOUND" } });
|
||||
expect(busy.statusCode).toBe(409);
|
||||
expect(busy.body).not.toContain("stack");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestApp } from "./test-app.js";
|
||||
|
||||
const apps: Array<ReturnType<typeof createTestApp>["app"]> = [];
|
||||
afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())));
|
||||
|
||||
describe("attachment and approval API", () => {
|
||||
it("streams exactly one multipart image and removes by opaque ID", async () => {
|
||||
const { app, attachmentStore } = createTestApp();
|
||||
apps.push(app);
|
||||
const boundary = "agent-studio-boundary";
|
||||
const body = Buffer.from([
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="chart.png"\r\nContent-Type: image/png\r\n\r\n`,
|
||||
"fake-png-bytes",
|
||||
`\r\n--${boundary}--\r\n`,
|
||||
].join(""));
|
||||
const uploaded = await app.inject({
|
||||
method: "POST", url: "/api/sessions/ses_1/attachments",
|
||||
headers: { "content-type": `multipart/form-data; boundary=${boundary}` }, payload: body,
|
||||
});
|
||||
const removed = await app.inject({ method: "DELETE", url: "/api/sessions/ses_1/attachments/att_1" });
|
||||
|
||||
expect(uploaded.statusCode).toBe(201);
|
||||
const uploadedBody = uploaded.json<{ attachment: { attachmentId: string }; requestId: string }>();
|
||||
expect(uploadedBody.attachment.attachmentId).toBe("att_1");
|
||||
expect(uploadedBody.requestId).toMatch(/^req_/);
|
||||
expect(attachmentStore.add).toHaveBeenCalled();
|
||||
expect(removed.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("resolves approval decisions through the authoritative broker", async () => {
|
||||
const { app, approvalBroker } = createTestApp();
|
||||
apps.push(app);
|
||||
const approved = await app.inject({ method: "POST", url: "/api/approvals/ap_1/approve" });
|
||||
const denied = await app.inject({ method: "POST", url: "/api/approvals/ap_2/deny" });
|
||||
|
||||
expect(approved.json()).toMatchObject({ approval: { status: "approved" } });
|
||||
expect(denied.json()).toMatchObject({ approval: { status: "denied" } });
|
||||
expect(approvalBroker.approve).toHaveBeenCalledWith("ap_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestApp } from "./test-app.js";
|
||||
|
||||
const apps: Array<ReturnType<typeof createTestApp>["app"]> = [];
|
||||
afterEach(async () => Promise.all(apps.splice(0).map((app) => app.close())));
|
||||
|
||||
describe("model API", () => {
|
||||
it("configures and returns only redacted model metadata", async () => {
|
||||
const { app, modelStore } = createTestApp();
|
||||
apps.push(app);
|
||||
const configured = await app.inject({ method: "PUT", url: "/api/sessions/ses_1/model-config", payload: {
|
||||
providerId: "training", api: "openai-responses", apiKey: "sk-super-secret",
|
||||
baseUrl: "https://api.example.com/v1", modelId: "training-model", supportsImages: true,
|
||||
} });
|
||||
const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/model-config" });
|
||||
|
||||
expect(configured.statusCode).toBe(200);
|
||||
expect(modelStore.configure).toHaveBeenCalled();
|
||||
expect(response.body).not.toContain("sk-super-secret");
|
||||
expect(response.body).not.toContain("server-only-secret");
|
||||
const body = response.json<{ modelConfig: { keyHint: string }; requestId: string }>();
|
||||
expect(body.modelConfig.keyHint).toBe("••••cret");
|
||||
expect(body.requestId).toMatch(/^req_/);
|
||||
});
|
||||
|
||||
it("clears credentials idempotently and returns an honest deferred connection test", async () => {
|
||||
const { app, modelStore } = createTestApp();
|
||||
apps.push(app);
|
||||
const cleared = await app.inject({ method: "DELETE", url: "/api/sessions/ses_1/model-config/credentials" });
|
||||
const tested = await app.inject({ method: "POST", url: "/api/sessions/ses_1/model-config/test" });
|
||||
|
||||
expect(cleared.statusCode).toBe(200);
|
||||
expect(modelStore.clearCredentials).toHaveBeenCalledWith("ses_1");
|
||||
const body = tested.json<{ status: string; requestId: string }>();
|
||||
expect(body.status).toBe("deferred");
|
||||
expect(body.requestId).toMatch(/^req_/);
|
||||
expect(tested.body).not.toContain("server-only-secret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { HarnessError } from "@agent-studio/harness";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
|
||||
export function createTestApp(): {
|
||||
app: FastifyInstance;
|
||||
session: { startRun: ReturnType<typeof vi.fn>; cancelRun: ReturnType<typeof vi.fn> };
|
||||
modelStore: {
|
||||
configure: ReturnType<typeof vi.fn>;
|
||||
getRedacted: ReturnType<typeof vi.fn>;
|
||||
clearCredentials: ReturnType<typeof vi.fn>;
|
||||
resolveConnection: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
attachmentStore: { add: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
||||
approvalBroker: { approve: ReturnType<typeof vi.fn>; deny: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
const session = {
|
||||
sessionId: "ses_1",
|
||||
workspaceRoot: "/safe/session",
|
||||
startRun: vi.fn(() => Promise.resolve({ runId: "run_1" })),
|
||||
cancelRun: vi.fn(() => Promise.resolve(true)),
|
||||
dispose: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
const registry = {
|
||||
create: vi.fn(() => Promise.resolve(session)),
|
||||
get: vi.fn((sessionId: string) => sessionId === "ses_1" ? session : undefined),
|
||||
delete: vi.fn((sessionId: string) => Promise.resolve(sessionId === "ses_1")),
|
||||
disposeAll: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
const modelStore = {
|
||||
configure: vi.fn(() => Promise.resolve()),
|
||||
getRedacted: vi.fn(() => ({
|
||||
providerId: "training", api: "openai-responses" as const, baseUrl: "https://api.example.com/v1",
|
||||
modelId: "training-model", supportsImages: true, configured: true, source: "session" as const, keyHint: "••••cret",
|
||||
})),
|
||||
clearCredentials: vi.fn(),
|
||||
resolveConnection: vi.fn(() => ({
|
||||
connection: { providerId: "training", api: "openai-responses" as const, modelId: "training-model", supportsImages: true },
|
||||
apiKey: "server-only-secret", source: "session" as const,
|
||||
})),
|
||||
};
|
||||
const attachmentStore = {
|
||||
add: vi.fn(async (_sessionId: string, upload: { stream: AsyncIterable<unknown> }) => {
|
||||
for await (const _chunk of upload.stream) {
|
||||
// Consume the multipart stream like the real attachment store.
|
||||
void _chunk;
|
||||
}
|
||||
return {
|
||||
attachmentId: "att_1", sessionId: "ses_1", name: "chart.png", mediaType: "image/png" as const,
|
||||
byteSize: 12, width: 4, height: 3, status: "ready" as const,
|
||||
};
|
||||
}),
|
||||
remove: vi.fn(() => Promise.resolve(true)),
|
||||
};
|
||||
const approvalBroker = {
|
||||
approve: vi.fn((approvalId: string) => ({ approvalId, status: "approved" as const })),
|
||||
deny: vi.fn((approvalId: string) => ({ approvalId, status: "denied" as const })),
|
||||
};
|
||||
const app = buildApp({
|
||||
sessionRegistry: registry,
|
||||
skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] },
|
||||
modelStore,
|
||||
attachmentStore,
|
||||
approvalBroker,
|
||||
testModelConnection: () => Promise.resolve({ status: "deferred", message: "Saved; validation will occur on first run." }),
|
||||
});
|
||||
return { app, session, modelStore, attachmentStore, approvalBroker };
|
||||
}
|
||||
|
||||
export function makeSessionBusy(session: { startRun: ReturnType<typeof vi.fn> }): void {
|
||||
session.startRun.mockRejectedValueOnce(new HarnessError("SESSION_BUSY", "Session is busy"));
|
||||
}
|
||||
@@ -59,6 +59,10 @@ export class HarnessSession {
|
||||
this.#runTimeoutMs = options.runTimeoutMs ?? 120_000;
|
||||
}
|
||||
|
||||
public get isBusy(): boolean {
|
||||
return this.#active !== undefined;
|
||||
}
|
||||
|
||||
public async startRun(input: MessageInput): Promise<{ runId: string }> {
|
||||
if (this.#disposed) throw new HarnessError("SESSION_DISPOSED", "Session has been disposed");
|
||||
if (this.#active !== undefined) throw new HarnessError("SESSION_BUSY", "Session already has an active run");
|
||||
|
||||
Reference in New Issue
Block a user