From 5e28e74ca12bfe5876b1f22501d728d00653d480 Mon Sep 17 00:00:00 2001 From: Wenxin Duan Date: Mon, 7 Sep 2026 16:37:51 +0800 Subject: [PATCH] feat(server): expose secure harness API --- apps/server/package.json | 1 + apps/server/src/app.ts | 59 ++++++++ apps/server/src/config.ts | 28 ++++ apps/server/src/errors/error-handler.ts | 58 ++++++++ apps/server/src/index.ts | 135 +++++++++++++++++- apps/server/src/routes/approvals.ts | 16 +++ apps/server/src/routes/attachments.ts | 44 ++++++ apps/server/src/routes/health.ts | 5 + apps/server/src/routes/models.ts | 45 ++++++ apps/server/src/routes/sessions.ts | 42 ++++++ apps/server/src/routes/skills.ts | 9 ++ apps/server/tests/api.test.ts | 56 ++++++++ apps/server/tests/attachment-api.test.ts | 41 ++++++ apps/server/tests/model-api.test.ts | 39 +++++ apps/server/tests/test-app.ts | 73 ++++++++++ .../harness/src/sessions/harness-session.ts | 4 + 16 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/config.ts create mode 100644 apps/server/src/errors/error-handler.ts create mode 100644 apps/server/src/routes/approvals.ts create mode 100644 apps/server/src/routes/attachments.ts create mode 100644 apps/server/src/routes/health.ts create mode 100644 apps/server/src/routes/models.ts create mode 100644 apps/server/src/routes/sessions.ts create mode 100644 apps/server/src/routes/skills.ts create mode 100644 apps/server/tests/api.test.ts create mode 100644 apps/server/tests/attachment-api.test.ts create mode 100644 apps/server/tests/model-api.test.ts create mode 100644 apps/server/tests/test-app.ts diff --git a/apps/server/package.json b/apps/server/package.json index d2fd752..becebd7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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" }, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..6a20523 --- /dev/null +++ b/apps/server/src/app.ts @@ -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; + readonly isBusy?: boolean; +}; + +export type AppDependencies = { + sessionRegistry: { + create(): Promise; + get(sessionId: string): ApiSession | undefined; + delete(sessionId: string): Promise; + disposeAll(): Promise; + }; + skillCatalog: Pick; + modelStore: ModelConnectionStore; + attachmentStore: Pick; + approvalBroker: Pick; + 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; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..8999ffb --- /dev/null +++ b/apps/server/src/config.ts @@ -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", + }; +} diff --git a/apps/server/src/errors/error-handler.ts b/apps/server/src/errors/error-handler.ts new file mode 100644 index 0000000..e403634 --- /dev/null +++ b/apps/server/src/errors/error-handler.ts @@ -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; +} { + 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): Record { + return Object.fromEntries( + Object.entries(details).filter(([key]) => !/key|token|secret|authorization|credential/i.test(key)), + ); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index cb0ff5c..d1b15fb 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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({ + 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 { + 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 }); +} diff --git a/apps/server/src/routes/approvals.ts b/apps/server/src/routes/approvals.ts new file mode 100644 index 0000000..8812808 --- /dev/null +++ b/apps/server/src/routes/approvals.ts @@ -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 }; + }); +} diff --git a/apps/server/src/routes/attachments.ts b/apps/server/src/routes/attachments.ts new file mode 100644 index 0000000..d419327 --- /dev/null +++ b/apps/server/src/routes/attachments.ts @@ -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(["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> | 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 }; + }); +} diff --git a/apps/server/src/routes/health.ts b/apps/server/src/routes/health.ts new file mode 100644 index 0000000..f42cedb --- /dev/null +++ b/apps/server/src/routes/health.ts @@ -0,0 +1,5 @@ +import type { FastifyInstance } from "fastify"; + +export function registerHealthRoute(app: FastifyInstance): void { + app.get("/health", (request) => ({ status: "ok", requestId: request.id })); +} diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts new file mode 100644 index 0000000..944ca0a --- /dev/null +++ b/apps/server/src/routes/models.ts @@ -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 }; + }); +} diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts new file mode 100644 index 0000000..3cf56cb --- /dev/null +++ b/apps/server/src/routes/sessions.ts @@ -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; +} diff --git a/apps/server/src/routes/skills.ts b/apps/server/src/routes/skills.ts new file mode 100644 index 0000000..6e5db95 --- /dev/null +++ b/apps/server/src/routes/skills.ts @@ -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, + })); +} diff --git a/apps/server/tests/api.test.ts b/apps/server/tests/api.test.ts new file mode 100644 index 0000000..55da734 --- /dev/null +++ b/apps/server/tests/api.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createTestApp, makeSessionBusy } from "./test-app.js"; + +const apps: Array["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"); + }); +}); diff --git a/apps/server/tests/attachment-api.test.ts b/apps/server/tests/attachment-api.test.ts new file mode 100644 index 0000000..9aed92c --- /dev/null +++ b/apps/server/tests/attachment-api.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createTestApp } from "./test-app.js"; + +const apps: Array["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"); + }); +}); diff --git a/apps/server/tests/model-api.test.ts b/apps/server/tests/model-api.test.ts new file mode 100644 index 0000000..fae06f3 --- /dev/null +++ b/apps/server/tests/model-api.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createTestApp } from "./test-app.js"; + +const apps: Array["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"); + }); +}); diff --git a/apps/server/tests/test-app.ts b/apps/server/tests/test-app.ts new file mode 100644 index 0000000..1d7127d --- /dev/null +++ b/apps/server/tests/test-app.ts @@ -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; cancelRun: ReturnType }; + modelStore: { + configure: ReturnType; + getRedacted: ReturnType; + clearCredentials: ReturnType; + resolveConnection: ReturnType; + }; + attachmentStore: { add: ReturnType; remove: ReturnType }; + approvalBroker: { approve: ReturnType; deny: ReturnType }; +} { + 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 }) => { + 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 }): void { + session.startRun.mockRejectedValueOnce(new HarnessError("SESSION_BUSY", "Session is busy")); +} diff --git a/packages/harness/src/sessions/harness-session.ts b/packages/harness/src/sessions/harness-session.ts index bbe9ce3..e3418dc 100644 --- a/packages/harness/src/sessions/harness-session.ts +++ b/packages/harness/src/sessions/harness-session.ts @@ -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");