feat: publish Agent Studio integrations and setup guide

This commit is contained in:
Wenxin Duan
2026-09-16 16:32:16 +08:00
parent 2ad089d68a
commit 05e6060490
106 changed files with 4942 additions and 198 deletions
+16
View File
@@ -1,4 +1,6 @@
import type {
ImageVideoService,
CmsConnection,
ApprovalBroker,
AttachmentStore,
EventStore,
@@ -18,6 +20,10 @@ import { registerEventRoute } from "./routes/events.js";
import { registerModelRoutes } from "./routes/models.js";
import { registerSessionRoutes } from "./routes/sessions.js";
import { registerSkillsRoute } from "./routes/skills.js";
import { registerReportRoutes } from "./routes/reports.js";
import { registerCmsRoutes } from "./routes/cms.js";
import { registerEditorRoutes } from "./routes/editor.js";
import { registerImageVideoRoutes } from "./routes/image-video.js";
import { SseRegistry, type SseRegistryPort } from "./sse/sse-registry.js";
export type ApiSession = SessionRecord & {
@@ -27,6 +33,8 @@ export type ApiSession = SessionRecord & {
};
export type AppDependencies = {
imageVideo?: ImageVideoService;
imageVideoPorts?: number[];
sessionRegistry: {
create(): Promise<ApiSession>;
get(sessionId: string): ApiSession | undefined;
@@ -38,6 +46,10 @@ export type AppDependencies = {
attachmentStore: Pick<AttachmentStore, "add" | "remove">;
approvalBroker: Pick<ApprovalBroker, "approve" | "deny">;
eventStore?: EventStore;
readReport?: (sessionId: string, reportId: string, format: string) => Promise<{ bytes: Buffer; contentType: string }>;
readEditorFrame?: (sessionId: string, jobId: string, frameId: string) => Promise<Buffer>;
editorForSession?: (sessionId:string)=>{workflowView:(jobId:string)=>unknown;review:(jobId:string,stage:"selection"|"plan",value:unknown)=>Promise<unknown>;video:(jobId:string,kind:"sources"|"plans",id:string)=>Promise<{path:string;size:number}>};
cmsForSession?: (sessionId: string) => CmsConnection;
sseRegistry?: SseRegistryPort;
testModelConnection?: (
resolved: ResolvedModelConnection,
@@ -57,6 +69,10 @@ export function buildApp(dependencies: AppDependencies) {
installErrorHandler(app);
registerHealthRoute(app);
registerSkillsRoute(app, effectiveDependencies);
registerReportRoutes(app, effectiveDependencies);
registerCmsRoutes(app, effectiveDependencies);
registerEditorRoutes(app, effectiveDependencies);
registerImageVideoRoutes(app, effectiveDependencies);
registerSessionRoutes(app, effectiveDependencies);
registerModelRoutes(app, effectiveDependencies);
registerAttachmentRoutes(app, effectiveDependencies);
+2
View File
@@ -47,6 +47,8 @@ function mapError(error: FastifyError | Error): {
}
function statusForHarnessCode(code: string): number {
if (code === "CMS_ORIGIN_DENIED" || code === "CMS_ADMIN_REQUIRED") return 403;
if (code === "CMS_RATE_LIMITED") return 429;
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;
+95 -7
View File
@@ -1,9 +1,26 @@
import { mkdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { delimiter, dirname, join, resolve } from "node:path";
import { homedir } from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
ImageVideoService,
ImageVideoMcpRemote,
createImageVideoCollector,
createImageVideoTools,
IMAGE_VIDEO_RESOURCES,
FileAttachmentStore,
CmsConnection,
EditorService,
SkillRunner,
createMediaTools,
createEditorTools,
EDITOR_RESOURCES,
HarnessSession,
HarnessError,
MinisReportService,
SwadsMcpClient,
createSwadsTools,
createMinisReportTool,
InMemoryApprovalBroker,
InMemoryEventStore,
InMemoryModelConnectionStore,
@@ -29,12 +46,21 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e
await mkdir(agentDir, { recursive: true, mode: 0o700 });
const eventStore = new InMemoryEventStore();
const dramaSkillRoot = resolve(environment.DRAMA_MINIS_SKILL_DIR ?? join(homedir(), ".codex", "skills", "drama-minis-diagnosis"));
const editorSkillRoot = resolve(environment.DRAMA_EDITOR_SKILL_DIR ?? join(homedir(), ".codex", "skills", "swads-short-drama-editor"));
const imageVideoSkillRoot = resolve(environment.IMAGE_VIDEO_SKILL_DIR ?? join(homedir(), ".codex", "skills", "swads-image-to-video"));
const imageVideoRemote = new ImageVideoMcpRemote({ token: environment.SWADS_MCP_TOKEN ?? "", receiptRoot: join(agentDir, "image-video") });
const cmsConnections = new Map<string, CmsConnection>();
const editors = new Map<string, EditorService>();
const swads = new SwadsMcpClient({ token: environment.SWADS_MCP_TOKEN ?? "" });
const reports = new WeakMap<HarnessSession, MinisReportService>();
const skillCatalog = new SkillCatalog({
cwd: projectRoot,
agentDir,
...(environment.AGENT_SKILLS_DIRS === undefined
? {}
: { extraDirsValue: environment.AGENT_SKILLS_DIRS }),
extraDirsValue: [dramaSkillRoot, editorSkillRoot, imageVideoSkillRoot, environment.AGENT_SKILLS_DIRS].filter(Boolean).join(delimiter),
resources: { [join(dramaSkillRoot, "SKILL.md")]: [
"references/metrics.md", "references/reporting-playbook.md", "assets/report-theme.css", "scripts/generate_report.py", "examples/sample-report.json", "examples/weekly-report-example.json",
], [join(editorSkillRoot, "SKILL.md")]: EDITOR_RESOURCES, [join(imageVideoSkillRoot, "SKILL.md")]: IMAGE_VIDEO_RESOURCES },
});
await skillCatalog.reload();
const environmentConnection = await readEnvironmentConnection(environment, config.allowPrivateLlmBaseUrls);
@@ -55,11 +81,17 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e
maxBytes: config.maxImageBytes,
maxAttachmentsPerMessage: config.maxImagesPerMessage,
});
const imageVideo = new ImageVideoService({ root: join(agentDir, "image-video"), attachments: attachmentStore, remote: imageVideoRemote, collect: createImageVideoCollector({ skillRoot: imageVideoSkillRoot, python: environment.PYTHON_EXECUTABLE ?? join(homedir(), ".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3") }) });
const registry = new InMemorySessionRegistry<HarnessSession>({
sessionsRoot,
createSession: ({ sessionId, workspaceRoot }) => {
const cms = new CmsConnection({ publishableKey: environment.CMS_SUPABASE_PUBLISHABLE_KEY ?? "" });
cmsConnections.set(sessionId, cms);
const editor = new EditorService({ cms, sessionId, runtimeRoot: join(projectRoot, ".agent-studio-runtime", "videos"),runner:new SkillRunner({skillRoot:editorSkillRoot,pythonExecutable:environment.PYTHON_EXECUTABLE??join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")}) });
editors.set(sessionId, editor);
let sessionSkillIndex = skillCatalog.index();
eventStore.append(sessionId, { type: "session.started", payload: { cwd: "." } });
return new HarnessSession({
const session = new HarnessSession({
sessionId,
workspaceRoot,
eventStore,
@@ -71,31 +103,86 @@ export async function buildDefaultApp(environment: NodeJS.ProcessEnv = process.e
sessionId,
runId,
eventStore,
skillIndex: skillCatalog.index(),
skillIndex: sessionSkillIndex,
}),
createAgentPort: async (input) => {
const workspace = await SafeWorkspace.create(workspaceRoot);
const snapshot = await skillCatalog.createSessionSnapshot(workspace.root);
sessionSkillIndex = snapshot.index;
const drama = snapshot.skills.find((skill) => skill.name === "drama-minis-diagnosis");
let reportService = reports.get(session);
if (drama !== undefined && reportService === undefined) {
reportService = new MinisReportService({ workspace, sessionId, skillRoot: drama.baseDir,
...(environment.PYTHON_EXECUTABLE === undefined ? {} : { pythonExecutable: environment.PYTHON_EXECUTABLE }),
...(environment.CHROME_EXECUTABLE === undefined ? {} : { chromeExecutable: environment.CHROME_EXECUTABLE }),
});
reports.set(session, reportService);
}
const service = reportService;
return createPiAgentSessionPortFactory({
agentDir,
skills: snapshot.skills,
appendSystemPrompt: ["用户选择 Skill 时必须先用 read 读取对应 SKILL.md。Drama Minis 任务必须按该 Skill 执行,并按相对路径读取 references。不要把 Skill 全文重复给用户。swads_whoami 和 metrics_catalog 是真实只读 MCP 工具;metrics_semantic_query 必须经用户确认周期、对比周期和范围,并在页面批准后才执行。不得用 mock_ads_metrics 分析真实 Minis。生成日报/周报必须用 generate_minis_report,使用本次查询返回的 sourceQueryId 和新的 JSON,不能使用示例数字。此受控工具代替 shell 运行 Python,提供 HTML、A4 PDF、JSON 下载。只在工具成功后声称文件已生成。缺少配置或数据时明确报错,不编造数据。"],
approvalBroker,
customTools: [
...(snapshot.skills.some(skill => skill.name === "swads-image-to-video") ? createImageVideoTools(imageVideo, sessionId) : []),
...(snapshot.skills.some((skill) => skill.name === "swads-short-drama-editor") ? [
...createEditorTools(cms,()=>({rendering:true,vision:modelStore.resolveConnection(sessionId).connection.supportsImages,humanReview:true})),
...createMediaTools(editor, () => modelStore.resolveConnection(sessionId).connection.supportsImages),
] : []),
createMockAdsMetricsTool(join(projectRoot, "fixtures", "ads-account.json")),
createSaveReportDraftTool(workspace),
...(service === undefined ? [] : [
...createSwadsTools(swads, (args, result) => service.recordQuery(args, result)),
createMinisReportTool(service),
]),
],
})(input);
},
});
return session;
},
});
return buildApp({
const app = buildApp({
imageVideo,
imageVideoPorts: [config.port, 5173, 4173],
editorForSession:(sessionId)=>{const editor=editors.get(sessionId);if(!editor)throw new HarnessError("SESSION_NOT_FOUND","Session not found");return editor;},
readEditorFrame: (sessionId, jobId, frameId) => {
const editor = editors.get(sessionId);
if (!editor) throw new HarnessError("FRAME_NOT_FOUND", "Frame not found in this Session");
return editor.readFrame(jobId, frameId);
},
cmsForSession: (sessionId) => {
const cms = cmsConnections.get(sessionId);
if (cms === undefined) throw new HarnessError("SESSION_NOT_FOUND", "Session not found");
return cms;
},
sessionRegistry: registry,
skillCatalog,
modelStore,
attachmentStore,
approvalBroker,
eventStore,
readReport: async (sessionId, reportId, format) => {
const session = registry.get(sessionId);
const service = session === undefined ? undefined : reports.get(session);
if (service === undefined) throw new HarnessError("REPORT_NOT_FOUND", "Report not found in this Session");
return service.read(reportId, format);
},
});
app.addHook("onClose", () => swads.close());
app.addHook("onClose", () => imageVideoRemote.close());
app.addHook("onClose", () => { editors.clear(); });
app.addHook("onClose", () => { for (const cms of cmsConnections.values()) cms.clear(); cmsConnections.clear(); });
app.addHook("onResponse", (request, _reply, done) => {
if (request.method === "DELETE" && /^\/api\/sessions\/ses_[a-zA-Z0-9_-]+$/.test(request.url)) {
const id = request.url.split("/").at(-1) ?? "";
if (registry.get(id) === undefined) { cmsConnections.get(id)?.clear(); cmsConnections.delete(id); editors.delete(id); }
}
done();
});
return app;
}
async function readEnvironmentConnection(
@@ -122,6 +209,7 @@ async function readEnvironmentConnection(
modelId,
api,
supportsImages: environment.AGENT_MODEL_SUPPORTS_IMAGES !== "false",
...(environment.AGENT_MODEL_MAX_TOKENS === undefined ? {} : { maxTokens: Number(environment.AGENT_MODEL_MAX_TOKENS) }),
...(baseUrl === undefined ? {} : { baseUrl }),
...(apiKey === undefined || apiKey === "" ? {} : { apiKey }),
};
+58
View File
@@ -0,0 +1,58 @@
import { HarnessError, type CmsConnection } from "@agent-studio/harness";
import type { FastifyInstance, FastifyRequest } 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_-]+$/) });
const Login = z.object({ email: z.string().email().max(254), password: z.string().min(1).max(1024) }).strict();
export function registerCmsRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
const attempts = new Map<string, { count: number; until: number }>();
const connection = (request: FastifyRequest, mutate = false): CmsConnection => {
const { sessionId } = Params.parse(request.params);
const session = requireSession(dependencies, sessionId);
if (mutate && session.isBusy === true) throw new HarnessError("SESSION_BUSY", "运行期间不能修改 CMS 连接");
if (dependencies.cmsForSession === undefined) throw new HarnessError("CMS_NOT_CONFIGURED", "CMS 连接不可用");
return dependencies.cmsForSession(sessionId);
};
const guard = (request: FastifyRequest): void => {
const origin = request.headers.origin;
if (request.headers["sec-fetch-site"] === "cross-site" || (origin !== undefined && origin !== `http://${request.headers.host ?? ""}`)) {
throw new HarnessError("CMS_ORIGIN_DENIED", "CMS 请求必须来自当前本机页面");
}
};
app.get("/api/sessions/:sessionId/cms/connection", (request, reply) => {
guard(request);
void reply.header("Cache-Control", "no-store");
return { connection: connection(request).status() };
});
app.post("/api/sessions/:sessionId/cms/connection", async (request, reply) => {
guard(request);
void reply.header("Cache-Control", "no-store");
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("CMS_INVALID_CONTENT_TYPE", "CMS login requires JSON");
const cms = connection(request, true);
const input = Login.parse(request.body);
const key = request.ip;
const now = Date.now();
for (const [id, value] of attempts) if (value.until < now) attempts.delete(id);
const record = attempts.get(key) ?? { count: 0, until: now + 60_000 };
if (record.count >= 5) throw new HarnessError("CMS_RATE_LIMITED", "登录尝试过多,请一分钟后再试");
record.count++; attempts.set(key, record);
try { return { connection: await cms.login(input.email, input.password) }; }
finally { input.password = ""; }
});
app.delete("/api/sessions/:sessionId/cms/connection", (request, reply) => {
guard(request);
void reply.header("Cache-Control", "no-store");
const cms = connection(request, true);
cms.clear();
return { connection: cms.status() };
});
app.get("/api/sessions/:sessionId/cms/dramas", async (request, reply) => {
guard(request);
void reply.header("Cache-Control", "no-store");
const query = z.object({ q: z.string().max(120).default(""), offset: z.coerce.number().int().min(0).max(10_000).default(0) }).parse(request.query);
return connection(request).listDramas(query.q, query.offset);
});
}
+48
View File
@@ -0,0 +1,48 @@
import { HarnessError } from "@agent-studio/harness";
import type { FastifyInstance,FastifyRequest } from "fastify";
import {createReadStream} from "node:fs";
import { z } from "zod";
import type { AppDependencies } from "../app.js";
import { requireSession } from "./sessions.js";
const Params = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), jobId: z.string().regex(/^job_[\w-]+$/), frameId: z.string().regex(/^frame_[\w-]+$/) }).strict();
export function registerEditorRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
const context=(request:FastifyRequest)=>{
const params=z.object({sessionId:z.string().regex(/^ses_[\w-]+$/),jobId:z.string().regex(/^job_[\w-]+$/)}).parse(request.params);
requireSession(dependencies,params.sessionId);
if(request.headers["sec-fetch-site"]==="cross-site"||(request.headers.origin!==undefined&&request.headers.origin!==`http://${request.headers.host??""}`))throw new HarnessError("CMS_ORIGIN_DENIED","媒体和审核仅限当前本机页面");
if(!dependencies.editorForSession)throw new HarnessError("EDITOR_JOB_NOT_FOUND","Editor unavailable");
return {...params,editor:dependencies.editorForSession(params.sessionId)};
};
app.get("/api/sessions/:sessionId/editor/:jobId/workflow",(request,reply)=>{const ctx=context(request);void reply.header("Cache-Control","no-store");return ctx.editor.workflowView(ctx.jobId);});
app.post("/api/sessions/:sessionId/editor/:jobId/review/:stage",async(request,reply)=>{
const ctx=context(request);const {stage}=z.object({stage:z.enum(["selection","plan"])}).parse(request.params);
if(!request.headers["content-type"]?.startsWith("application/json"))throw new HarnessError("CMS_INVALID_CONTENT_TYPE","Review requires JSON");
void reply.header("Cache-Control","no-store");return ctx.editor.review(ctx.jobId,stage,request.body);
});
for(const kind of ["sources","plans"] as const){
app.get(`/api/sessions/:sessionId/editor/:jobId/${kind}/:mediaId${kind==="plans"?"/preview":""}`,async(request,reply)=>{
const ctx=context(request);const {mediaId}=z.object({mediaId:z.string().regex(/^[\w-]{1,100}$/)}).parse(request.params);
const media=await ctx.editor.video(ctx.jobId,kind,mediaId);
void reply.header("Cache-Control","no-store").header("X-Content-Type-Options","nosniff").header("Accept-Ranges","bytes").type("video/mp4");
const query=z.object({download:z.enum(["0","1"]).optional()}).parse(request.query);
if(query.download==="1")void reply.header("Content-Disposition",`attachment; filename="${kind==="plans"?"REVIEW-preview":"licensed-source"}.mp4"`);
const range=request.headers.range;
if(range!==undefined){
const match=/^bytes=(\d*)-(\d*)$/.exec(range);let start=0;let end=media.size-1;
if(match?.[1]){start=Number(match[1]);if(match[2])end=Math.min(Number(match[2]),end);}
else if(match?.[2]){start=Math.max(0,media.size-Number(match[2]));}
if(!match||(!match[1]&&!match[2])||!Number.isSafeInteger(start)||!Number.isSafeInteger(end)||start>end||start>=media.size||end<0){return reply.code(416).header("Content-Range",`bytes */${String(media.size)}`).send();}
return reply.code(206).header("Content-Range",`bytes ${String(start)}-${String(end)}/${String(media.size)}`).header("Content-Length",end-start+1).send(createReadStream(media.path,{start,end}));
}
return reply.header("Content-Length",media.size).send(createReadStream(media.path));
});
}
app.get("/api/sessions/:sessionId/editor/:jobId/frames/:frameId", async (request, reply) => {
const { sessionId, jobId, frameId } = Params.parse(request.params);
requireSession(dependencies, sessionId);
if (!dependencies.readEditorFrame) throw new HarnessError("FRAME_NOT_FOUND", "Frame not found");
const bytes = await dependencies.readEditorFrame(sessionId, jobId, frameId);
return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff").type("image/jpeg").send(bytes);
});
}
+13
View File
@@ -30,6 +30,19 @@ export function registerImageVideoRoutes(app: FastifyInstance, dependencies: App
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Draft creation requires JSON");
void reply.header("Cache-Control", "no-store"); return { task: await service.create(sessionId, ImageVideoDraftSchema.parse(request.body)) };
});
app.post("/api/sessions/:sessionId/image-video/tasks/:taskId/continue", async (request, reply) => {
const service = context(request);
const { sessionId, taskId } = z.object({ sessionId: z.string().regex(/^ses_[\w-]+$/), taskId: taskIdSchema }).parse(request.params);
const session = requireSession(dependencies, sessionId);
if (session.isBusy) throw new HarnessError("SESSION_BUSY", "请等待当前会话结束再接续任务。");
if (!request.headers["content-type"]?.startsWith("application/json")) throw new HarnessError("I2V_JSON_REQUIRED", "Continuation requires JSON");
const { expectedRevision } = z.object({ confirmed: z.literal(true), expectedRevision: z.number().int().nonnegative() }).strict().parse(request.body);
void reply.header("Cache-Control", "no-store");
return { task: await service.continueTask(sessionId, taskId, expectedRevision, owner => dependencies.sessionRegistry.get(owner) !== undefined, () => {
const current = requireSession(dependencies, sessionId);
if (current !== session || current.isBusy) throw new HarnessError("SESSION_BUSY", "会话状态已改变,请重新确认接续。");
}) };
});
for (const kind of ["reference", "video"] as const) app.get(`/api/image-video/tasks/:taskId/${kind}`, async (request, reply) => {
const service = context(request); const { taskId } = z.object({ taskId: taskIdSchema }).parse(request.params);
const query = z.object({ download: z.literal("1").optional() }).strict().parse(request.query);
+20
View File
@@ -0,0 +1,20 @@
import { HarnessError } 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_[\w-]+$/), reportId: z.string().regex(/^rpt_[\w-]+$/), format: z.enum(["html", "pdf", "json"]) }).strict();
export function registerReportRoutes(app: FastifyInstance, dependencies: AppDependencies): void {
app.get("/api/sessions/:sessionId/reports/:reportId/:format", async (request, reply) => {
const { sessionId, reportId, format } = Params.parse(request.params);
requireSession(dependencies, sessionId);
if (dependencies.readReport === undefined) throw new HarnessError("REPORT_NOT_FOUND", "Report not found");
const file = await dependencies.readReport(sessionId, reportId, format);
return reply.header("Cache-Control", "no-store").header("X-Content-Type-Options", "nosniff")
.header("Content-Security-Policy", "sandbox allow-scripts; default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:")
.header("Content-Disposition", `attachment; filename="${reportId}.${format}"`)
.type(file.contentType).send(file.bytes);
});
}
+21
View File
@@ -0,0 +1,21 @@
import { CmsConnection } from "@agent-studio/harness";
import { describe, expect, it } from "vitest";
import { createTestApp } from "./test-app.js";
describe("CMS API safety", () => {
it("exposes only redacted connection status and rejects cross-origin login before contacting CMS", async () => {
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: () => Promise.reject(new Error("Must not reach CMS")) });
const { app } = createTestApp({ cmsForSession: () => cms });
try {
const status = await app.inject({ method: "GET", url: "/api/sessions/ses_1/cms/connection" });
expect(status.statusCode).toBe(200);
expect(status.json()).toMatchObject({ connection: { connected: false, configured: true } });
expect(status.headers["cache-control"]).toBe("no-store");
const denied = await app.inject({ method: "POST", url: "/api/sessions/ses_1/cms/connection", headers: { origin: "https://attacker.example", "sec-fetch-site": "cross-site" }, payload: { email: "fixture@example.com", password: "never-expose" } });
expect(denied.statusCode).toBe(403);
expect(denied.body).not.toContain("never-expose");
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/cms/connection" })).statusCode).toBe(404);
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/cms/dramas" })).json()).toMatchObject({ error: { code: "CMS_LOGIN_REQUIRED" } });
} finally { await app.close(); }
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { HarnessError } from "@agent-studio/harness";
import { createTestApp } from "./test-app.js";
import {mkdtemp,writeFile,rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
describe("private editor frames", () => {
it("only serves a registered Session frame and forbids caching", async () => {
const { app } = createTestApp({ readEditorFrame: (sessionId, jobId, frameId) => {
if (sessionId !== "ses_1" || jobId !== "job_known" || frameId !== "frame_known") throw new HarnessError("FRAME_NOT_FOUND", "Missing frame");
return Promise.resolve(Buffer.from([255, 216, 255, 217]));
} });
try {
const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/editor/job_known/frames/frame_known" });
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toBe("image/jpeg");
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.rawPayload).toEqual(Buffer.from([255, 216, 255, 217]));
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/editor/job_known/frames/frame_known" })).statusCode).toBe(404);
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/editor/job_known/frames/secret.env" })).statusCode).toBe(400);
} finally { await app.close(); }
});
});
it("streams only registered media ranges and keeps review mutations same-origin",async()=>{
const root=await mkdtemp(join(tmpdir(),"editor-api-"));const path=join(root,"preview.mp4");await writeFile(path,Buffer.from("0123456789"));
const {app}=createTestApp({editorForSession:()=>({workflowView:()=>({jobId:"job_test"}),review:()=>Promise.resolve({saved:true}),video:()=>Promise.resolve({path,size:10})})});
try{
const range=await app.inject({method:"GET",url:"/api/sessions/ses_1/editor/job_test/plans/plan_test/preview",headers:{range:"bytes=2-5"}});
expect(range.statusCode).toBe(206);expect(range.body).toBe("2345");expect(range.headers["content-range"]).toBe("bytes 2-5/10");expect(range.headers["content-type"]).toBe("video/mp4");
expect((await app.inject({method:"GET",url:"/api/sessions/ses_1/editor/job_test/plans/plan_test/preview",headers:{range:"bytes=20-30"}})).statusCode).toBe(416);
expect((await app.inject({method:"GET",url:"/api/sessions/ses_missing/editor/job_test/plans/plan_test/preview"})).statusCode).toBe(404);
expect((await app.inject({method:"POST",url:"/api/sessions/ses_1/editor/job_test/review/selection",headers:{origin:"https://evil.example","content-type":"application/json"},payload:{}})).statusCode).toBeGreaterThanOrEqual(400);
expect((await app.inject({method:"POST",url:"/api/sessions/ses_1/editor/job_test/review/selection",payload:{}})).json()).toEqual({saved:true});
}finally{await app.close();await rm(root,{recursive:true,force:true});}
});
+34 -1
View File
@@ -13,10 +13,43 @@ async function fixture() {
const image = await attachments.add("ses_1", { name: "pixel.png", mediaType: "image/png", stream: Readable.from(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAE0lEQVQImWP4z8DwnwGM/zMwAAAf7gP9qS/A4gAAAABJRU5ErkJggg==", "base64")) });
const service = new ImageVideoService({ root: join(root, "tasks"), attachments, remote: {} as never, collect: () => Promise.reject(new Error("No external operations permitted")) });
const dependencies = { imageVideo: service, sessionRegistry: { get: (id: string) => id === "ses_1" ? { sessionId: id } : undefined, disposeAll: async () => {} }, skillCatalog: { list: () => [] }, modelStore: {}, attachmentStore: attachments, approvalBroker: {} } as unknown as AppDependencies;
return { app: buildApp(dependencies), service, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } };
return { app: buildApp(dependencies), service, dependencies, draft: { attachmentId: image.attachmentId, title: "Reference", prompt: "Natural motion and ambient sound", durationSeconds: 10 } };
}
describe("image video local API", () => {
it("does not persist a target session that disappeared during continuation", async () => {
const { app, service, dependencies, draft } = await fixture();
const task = await service.create("ses_1", draft);
let lookups = 0;
dependencies.sessionRegistry.get = id => id === "ses_new" && lookups++ === 0 ? { sessionId: id } as never : undefined;
try {
const result = await app.inject({ method: "POST", url: `/api/sessions/ses_new/image-video/tasks/${task.id}/continue`, payload: { confirmed: true, expectedRevision: 0 } });
expect(result.statusCode).toBe(404);
expect((await service.owned("ses_1", task.id)).revision).toBe(0);
} finally { await app.close(); }
});
it("requires an explicit same-origin confirmation to continue an orphaned task in a live session", async () => {
const { app, service, dependencies, draft } = await fixture();
const task = await service.create("ses_1", draft);
const liveSession = { sessionId: "ses_new" } as never;
dependencies.sessionRegistry.get = id => id === "ses_new" ? liveSession : undefined;
const url = `/api/sessions/ses_new/image-video/tasks/${task.id}/continue`;
const payload = { confirmed: true, expectedRevision: 0 };
try {
expect((await app.inject({ method: "POST", url, payload: { expectedRevision: 0 } })).statusCode).toBe(400);
expect((await app.inject({ method: "POST", url, payload: { ...payload, confirmed: false } })).statusCode).toBe(400);
expect((await app.inject({ method: "POST", url, payload, headers: { origin: "https://evil.example" } })).statusCode).toBeGreaterThanOrEqual(400);
expect((await app.inject({ method: "POST", url, payload, headers: { "sec-fetch-site": "cross-site" } })).statusCode).toBeGreaterThanOrEqual(400);
expect((await app.inject({ method: "POST", url: url.replace("ses_new", "ses_unknown"), payload })).statusCode).toBe(404);
expect((await app.inject({ method: "POST", url, payload: { ...payload, prompt: "changed" } })).statusCode).toBe(400);
await expect(service.owned("ses_new", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
const result = await app.inject({ method: "POST", url, payload });
expect(result.statusCode).toBe(200);
expect(result.json<{ task: ImageVideoPublicTask }>().task).toMatchObject({ id: task.id, state: "draft", revision: 1 });
expect((await service.owned("ses_new", task.id)).prompt).toBe(draft.prompt);
expect((await service.list())).toHaveLength(1);
} finally { await app.close(); }
});
it("creates locally, persists history independently from Sessions and serves registered reference ranges", async () => {
const { app, service, draft } = await fixture();
try {
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { createTestApp } from "./test-app.js";
describe("report download API", () => {
it("requires a known Session and serves only a fixed report format with sandbox and no-store headers", async () => {
const { app } = createTestApp({
readReport: (_session, _report, format) => Promise.resolve({ bytes: Buffer.from("<!doctype html><p>report</p>"), contentType: format === "html" ? "text/html" : "application/json" }),
});
try {
const response = await app.inject({ method: "GET", url: "/api/sessions/ses_1/reports/rpt_test/html" });
expect(response.statusCode).toBe(200);
expect(response.body).toContain("<p>report</p>");
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.headers["content-security-policy"]).toContain("sandbox");
expect(response.headers["content-disposition"]).toContain("attachment");
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_missing/reports/rpt_test/html" })).statusCode).toBe(404);
expect((await app.inject({ method: "GET", url: "/api/sessions/ses_1/reports/rpt_test/env" })).statusCode).toBe(400);
} finally { await app.close(); }
});
});
+6 -2
View File
@@ -1,9 +1,9 @@
import { HarnessError, InMemoryEventStore } from "@agent-studio/harness";
import type { FastifyInstance } from "fastify";
import { vi } from "vitest";
import { buildApp } from "../src/app.js";
import { buildApp, type AppDependencies } from "../src/app.js";
export function createTestApp(): {
export function createTestApp(options: { editorForSession?:AppDependencies["editorForSession"]; readReport?: AppDependencies["readReport"]; readEditorFrame?: AppDependencies["readEditorFrame"]; cmsForSession?: AppDependencies["cmsForSession"] } = {}): {
app: FastifyInstance;
session: { startRun: ReturnType<typeof vi.fn>; cancelRun: ReturnType<typeof vi.fn> };
modelStore: {
@@ -60,6 +60,10 @@ export function createTestApp(): {
};
const eventStore = new InMemoryEventStore();
const app = buildApp({
...(options.editorForSession===undefined?{}:{editorForSession:options.editorForSession}),
...(options.readEditorFrame === undefined ? {} : { readEditorFrame: options.readEditorFrame }),
...(options.readReport === undefined ? {} : { readReport: options.readReport }),
...(options.cmsForSession === undefined ? {} : { cmsForSession: options.cmsForSession }),
sessionRegistry: registry,
skillCatalog: { list: () => [{ name: "ads-analysis", description: "Analyze", source: "project", filePath: ".agents/skills/ads-analysis/SKILL.md", diagnostics: [] }] },
modelStore,