feat(server): expose secure harness API

This commit is contained in:
Wenxin Duan
2026-09-07 16:37:51 +08:00
parent 64ca126047
commit 5e28e74ca1
16 changed files with 654 additions and 1 deletions
+1
View File
@@ -7,6 +7,7 @@
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"lint": "eslint 'src/**/*.ts' 'tests/**/*.ts' --no-error-on-unmatched-pattern --max-warnings 0", "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", "typecheck": "tsc -p tsconfig.json",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
+59
View File
@@ -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;
}
+28
View File
@@ -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",
};
}
+58
View File
@@ -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
View File
@@ -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 });
}
+16
View File
@@ -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 };
});
}
+44
View File
@@ -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 };
});
}
+5
View File
@@ -0,0 +1,5 @@
import type { FastifyInstance } from "fastify";
export function registerHealthRoute(app: FastifyInstance): void {
app.get("/health", (request) => ({ status: "ok", requestId: request.id }));
}
+45
View File
@@ -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 };
});
}
+42
View File
@@ -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;
}
+9
View File
@@ -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,
}));
}
+56
View File
@@ -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");
});
});
+41
View File
@@ -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");
});
});
+39
View File
@@ -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");
});
});
+73
View File
@@ -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; this.#runTimeoutMs = options.runTimeoutMs ?? 120_000;
} }
public get isBusy(): boolean {
return this.#active !== undefined;
}
public async startRun(input: MessageInput): Promise<{ runId: string }> { public async startRun(input: MessageInput): Promise<{ runId: string }> {
if (this.#disposed) throw new HarnessError("SESSION_DISPOSED", "Session has been disposed"); 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"); if (this.#active !== undefined) throw new HarnessError("SESSION_BUSY", "Session already has an active run");