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
+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"));
}