feat: publish Agent Studio integrations and setup guide
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import {describe,it,expect} from "vitest";
|
||||
import {assertAnalysisBinding} from "../src/media/analysis-binding.js";
|
||||
|
||||
const expected={analysisId:"analysis_real",dramaId:"drama_real",fetchedAt:"2026-09-14T00:00:00Z",title:"Actual title",synopsis:"Actual CMS synopsis",tags:["Mystery"],episodeCount:5,
|
||||
sources:[{episodeId:"e1",episodeNo:1,duration:30,subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nWhere is she?",frames:[{id:"f1",time:2}]},{episodeId:"e3",episodeNo:3,duration:30,subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nFind her.",frames:[{id:"f3",time:2}]},{episodeId:"e5",episodeNo:5,duration:30,subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nWho did this?",frames:[{id:"f5",time:2}]}]};
|
||||
function analysis(){return {analysis_id:"analysis_real",drama:{drama_id:"drama_real",title:"Actual title",synopsis:"Actual CMS synopsis",tags:["Mystery"],episode_count:5},cms_source:{fetched_at:expected.fetchedAt},evidence:{
|
||||
frame_evidence:[{episode:1,episode_id:"e1",frame_id:"f1",time:2,claim:"A person stands"},{episode:3,episode_id:"e3",frame_id:"f3",time:2,claim:"A person speaks"}],
|
||||
subtitle_evidence:[{episode:1,episode_id:"e1",story_position:"opening",quote:"Where is she?"},{episode:3,episode_id:"e3",story_position:"middle",quote:"Find her."},{episode:5,episode_id:"e5",story_position:"late",quote:"Who did this?"}]}};}
|
||||
describe("analysis evidence binding",()=>{
|
||||
it("accepts only the actual job identity, CMS record and registered frame/subtitle evidence",()=>{
|
||||
expect(()=>assertAnalysisBinding(analysis(),expected)).not.toThrow();
|
||||
const changed=analysis();changed.analysis_id="analysis_other";
|
||||
expect(()=>assertAnalysisBinding(changed,expected)).toThrow();
|
||||
changed.analysis_id="analysis_real";changed.drama.synopsis="Model invented synopsis";
|
||||
expect(()=>assertAnalysisBinding(changed,expected)).toThrow();
|
||||
});
|
||||
it("rejects invented frames, mismatched timestamps, duplicate frames and invented subtitle quotes",()=>{
|
||||
const unknown=analysis();unknown.evidence.frame_evidence[0]!.frame_id="unknown";
|
||||
expect(()=>assertAnalysisBinding(unknown,expected)).toThrow();
|
||||
const timestamp=analysis();timestamp.evidence.frame_evidence[0]!.time=29;
|
||||
expect(()=>assertAnalysisBinding(timestamp,expected)).toThrow();
|
||||
const duplicate=analysis();duplicate.evidence.frame_evidence[1]=duplicate.evidence.frame_evidence[0]!;
|
||||
expect(()=>assertAnalysisBinding(duplicate,expected)).toThrow();
|
||||
const quote=analysis();quote.evidence.subtitle_evidence[0]!.quote="Invented dialogue";
|
||||
expect(()=>assertAnalysisBinding(quote,expected)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CmsConnection } from "../src/cms/cms-connection.js";
|
||||
|
||||
const dramaId = "11111111-1111-4111-8111-111111111111";
|
||||
const episodeId = "22222222-2222-4222-8222-222222222222";
|
||||
const userId = "33333333-3333-4333-8333-333333333333";
|
||||
const token = "test-access-secret";
|
||||
function server(role = "admin") {
|
||||
const requests: { url: URL; init: RequestInit | undefined }[] = [];
|
||||
const fetcher: typeof fetch = (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
||||
requests.push({ url, init });
|
||||
const user = { id: userId, aud: "authenticated", role: "authenticated", email: "fixture@example.com", app_metadata: { role }, user_metadata: { role: "admin" }, created_at: "2026-01-01T00:00:00Z" };
|
||||
let data: unknown;
|
||||
if (url.pathname === "/auth/v1/token") data = { access_token: token, refresh_token: "refresh-secret", token_type: "bearer", expires_in: 3600, user };
|
||||
else if (url.pathname === "/auth/v1/user") data = user;
|
||||
else if (url.pathname === "/rest/v1/drama") data = [{ id: dramaId, title: "Test drama", synopsis: "Fixture only", language: "en", total_episodes: 2, status: "published" }];
|
||||
else if (url.pathname === "/rest/v1/episode") data = [{ id: episodeId, drama_id: dramaId, episode_no: 1, title: "One", duration_seconds: 90, status: "published" }];
|
||||
else if (url.pathname === "/rest/v1/drama_category") data = [{drama_id:dramaId,category:{name:"Mystery"}}];
|
||||
else if (url.pathname === "/rest/v1/episode_source") data = [{ episode_id: episodeId, provider: "fixture", playback_path: "private/fixture.m3u8" }];
|
||||
else if (url.pathname === "/rest/v1/subtitle_track") data = [{ episode_id: episodeId, locale: "en", path: "private/fixture.srt" }];
|
||||
else if (url.pathname.endsWith("/subtitles/en.vtt")) return Promise.resolve(new Response("WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nFixture", { headers: { "content-type": "text/vtt" } }));
|
||||
else return Promise.resolve(new Response("unexpected destination", { status: 500 }));
|
||||
return Promise.resolve(new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }));
|
||||
};
|
||||
return { requests, fetcher };
|
||||
}
|
||||
|
||||
describe("CMS connection", () => {
|
||||
it("binds signing to the selected drama and keeps it on the fixed CMS admin route", async () => {
|
||||
const fixture = server();
|
||||
const cms = new CmsConnection({publishableKey:"sb_publishable_test",fetch:async(input,init)=>{
|
||||
const url=new URL(typeof input==="string"?input:input instanceof URL?input.href:input.url);
|
||||
if(url.pathname.endsWith(`/cms/playback/${episodeId}`)) {
|
||||
expect(url.origin).toBe("https://swave.pencil-stub.com");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(`Bearer ${token}`);
|
||||
return new Response(JSON.stringify({url:"https://video.pencil-stub.com/fixture/master.m3u8?auth_key=private",expires_at:new Date(Date.now()+600000).toISOString()}));
|
||||
}
|
||||
return fixture.fetcher(input,init);
|
||||
}});
|
||||
await cms.login("fixture@example.com","secret");
|
||||
await expect(cms.playback(dramaId,userId)).rejects.toMatchObject({code:"CMS_EPISODE_NOT_FOUND"});
|
||||
expect((await cms.playback(dramaId,episodeId)).url).toContain("https://video.pencil-stub.com/");
|
||||
expect(JSON.stringify(cms.status())).not.toContain("auth_key");
|
||||
});
|
||||
it("blocks content before login and never sends credentials to a model-facing status", async () => {
|
||||
const { fetcher } = server();
|
||||
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
|
||||
await expect(cms.listDramas("", 0)).rejects.toMatchObject({ code: "CMS_LOGIN_REQUIRED" });
|
||||
await cms.login("fixture@example.com", "password-secret");
|
||||
expect(cms.status()).toMatchObject({ configured: true, connected: true });
|
||||
expect(JSON.stringify(cms.status())).not.toMatch(/password-secret|test-access-secret|refresh-secret|fixture@example/);
|
||||
expect((await cms.listDramas("", 0)).dramas[0]?.title).toBe("Test drama");
|
||||
cms.clear();
|
||||
expect(cms.status().connected).toBe(false);
|
||||
await expect(cms.listDramas("", 0)).rejects.toMatchObject({ code: "CMS_LOGIN_REQUIRED" });
|
||||
});
|
||||
|
||||
it("rejects user-editable administrator claims and discards the session", async () => {
|
||||
const { fetcher } = server("viewer");
|
||||
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
|
||||
await expect(cms.login("fixture@example.com", "secret")).rejects.toMatchObject({ code: "CMS_ADMIN_REQUIRED" });
|
||||
expect(cms.status().connected).toBe(false);
|
||||
});
|
||||
|
||||
it("reports incomplete inventory without leaking playback or subtitle paths", async () => {
|
||||
const { fetcher } = server();
|
||||
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
|
||||
await cms.login("fixture@example.com", "secret");
|
||||
const inventory = await cms.inventory(dramaId);
|
||||
expect(inventory.complete).toBe(false);
|
||||
expect(inventory.drama.tags).toEqual(["Mystery"]);
|
||||
expect(inventory.missingEpisodeNumbers).toEqual([2]);
|
||||
expect(inventory.episodes[0]).toMatchObject({ episodeNo: 1, hasSource: true, subtitleLocales: ["en"] });
|
||||
expect(JSON.stringify(inventory)).not.toContain("private/");
|
||||
expect(inventory.fetchedAt).toMatch(/^\d{4}-/);
|
||||
});
|
||||
|
||||
it("binds subtitle access to an actual episode in the chosen drama", async () => {
|
||||
const { fetcher, requests } = server();
|
||||
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: fetcher });
|
||||
await cms.login("fixture@example.com", "secret");
|
||||
await expect(cms.subtitle(dramaId, userId, "en")).rejects.toMatchObject({ code: "CMS_EPISODE_NOT_FOUND" });
|
||||
expect((await cms.subtitle(dramaId, episodeId, "en")).text).toContain("WEBVTT");
|
||||
const subtitleRequest = requests.find((request) => request.url.pathname.endsWith(".vtt"));
|
||||
expect(subtitleRequest?.url.origin).toBe("https://swave.pencil-stub.com");
|
||||
expect(subtitleRequest?.init?.redirect).toBe("error");
|
||||
expect(new Headers(subtitleRequest?.init?.headers).get("authorization")).toBe(`Bearer ${token}`);
|
||||
});
|
||||
|
||||
it("rejects a late login result after the connection was cleared", async () => {
|
||||
const { fetcher } = server();
|
||||
let release: (() => void) | undefined;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const cms = new CmsConnection({ publishableKey: "sb_publishable_test", fetch: async (input, init) => { await gate; return fetcher(input, init); } });
|
||||
const pending = cms.login("fixture@example.com", "secret");
|
||||
cms.clear();
|
||||
release?.();
|
||||
await expect(pending).rejects.toMatchObject({ code: "CMS_LOGIN_CANCELLED" });
|
||||
expect(cms.status().connected).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import {describe,it,expect} from "vitest";
|
||||
import {EditorService} from "../src/media/editor-service.js";
|
||||
import {CmsConnection} from "../src/cms/cms-connection.js";
|
||||
|
||||
describe("editor job boundary",()=>{
|
||||
it("requires CMS login before preparing media and rejects unknown jobs",async()=>{
|
||||
const service=new EditorService({cms:new CmsConnection({publishableKey:""}),runtimeRoot:"/unused/editor-tests",sessionId:"ses_test"});
|
||||
await expect(service.prepare({dramaId:"06aa396d-175a-48ed-ad76-02786ef4d0a9",episodeNumbers:[1,3,5]},new AbortController().signal)).rejects.toMatchObject({code:"CMS_LOGIN_REQUIRED"});
|
||||
expect(()=>service.status("job_unknown")).toThrow();
|
||||
await expect(service.submitAnalysis("job_unknown",{},new AbortController().signal)).rejects.toMatchObject({code:"EDITOR_JOB_NOT_FOUND"});
|
||||
expect(()=>service.workflowView("job_unknown")).toThrow();
|
||||
await expect(service.review("job_unknown","selection",{})).rejects.toMatchObject({code:"EDITOR_JOB_NOT_FOUND"});
|
||||
});
|
||||
it("rejects duplicate episode requests and oversized preparation scope before fetching",async()=>{
|
||||
const service=new EditorService({cms:new CmsConnection({publishableKey:""}),runtimeRoot:"/unused/editor-tests",sessionId:"ses_test"});
|
||||
for(const numbers of [[1,1],[1,2,3,4,5,6],[]])await expect(service.prepare({dramaId:"06aa396d-175a-48ed-ad76-02786ef4d0a9",episodeNumbers:numbers},new AbortController().signal)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import {it,expect} from "vitest";
|
||||
import {cutSubtitles} from "../src/media/editor-subtitles.js";
|
||||
it("maps original English cues to the selected timeline without invented text",()=>{
|
||||
const result=cutSubtitles([{subtitle:"WEBVTT\n\n00:00:02.000 --> 00:00:04.000\nA & B.\n\n00:00:07.000 --> 00:00:09.000\nDo not clip this line.\n",sourceStart:1,sourceEnd:8,targetStart:0,segmentId:"one"}]);
|
||||
expect(result.srt).toContain("00:00:01,000 --> 00:00:03,000\nA & B.");
|
||||
expect(result.boundaryWarnings).toEqual(["one"]);
|
||||
expect(result.srt).toContain("00:00:06,000 --> 00:00:07,000");
|
||||
expect(()=>cutSubtitles([{subtitle:"invalid",sourceStart:0,sourceEnd:10,targetStart:0,segmentId:"one"}])).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decideToolPolicy } from "../src/tools/tool-policy.js";
|
||||
|
||||
describe("editor content approval", () => {
|
||||
it("requires approval before exposing licensed CMS content to the model", () => {
|
||||
expect(decideToolPolicy("cms_connection_status")).toBe("allow");
|
||||
expect(decideToolPolicy("editor_job_status")).toBe("allow");
|
||||
expect(decideToolPolicy("submit_drama_analysis")).toBe("allow");
|
||||
expect(decideToolPolicy("propose_drama_selection")).toBe("allow");
|
||||
expect(decideToolPolicy("submit_editor_plan")).toBe("allow");
|
||||
expect(decideToolPolicy("render_editor_preview")).toBe("ask");
|
||||
expect(decideToolPolicy("approve_editor_review")).toBe("deny");
|
||||
expect(decideToolPolicy("prepare_drama_sources")).toBe("ask");
|
||||
expect(decideToolPolicy("read_drama_frames")).toBe("ask");
|
||||
for (const name of ["cms_list_dramas", "cms_drama_inventory", "cms_read_subtitle"]) expect(decideToolPolicy(name)).toBe("ask");
|
||||
expect(decideToolPolicy("cms_login")).toBe("deny");
|
||||
expect(decideToolPolicy("render_exact")).toBe("deny");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {it,expect} from "vitest";
|
||||
import {mkdtemp,rm,writeFile,readFile} from "node:fs/promises";
|
||||
import {tmpdir,homedir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
import {createHash} from "node:crypto";
|
||||
import {EditorWorkflow} from "../src/media/editor-workflow.js";
|
||||
import {SkillRunner} from "../src/media/skill-runner.js";
|
||||
import {analysisFixture} from "./fixtures/editor-analysis.js";
|
||||
import {planFixture} from "./fixtures/editor-plan.js";
|
||||
import {mediaRuntime,runMedia} from "../src/media/media-worker.js";
|
||||
|
||||
it("requires source review before accepting a plan and never treats model PASS as human review",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"workflow-"));
|
||||
try{
|
||||
await writeFile(join(root,"target-drama-analysis.json"),JSON.stringify({withheld_information:["The answer"]}));
|
||||
const workflow=new EditorWorkflow({root,sessionId:"ses_test",jobId:"job_test",analysisId:"analysis_test",dramaId:"drama_test",sources:[{episodeId:"ep_test",episodeNo:1,source:join(root,"source.mp4"),duration:20,sha256:"abc",subtitle:"WEBVTT"}],runner:new SkillRunner({skillRoot:"/unused",pythonExecutable:"/unused"})});
|
||||
await expect(workflow.submitPlan({},new AbortController().signal)).rejects.toMatchObject({code:"SELECTION_REVIEW_REQUIRED"});
|
||||
const selected=await workflow.proposeSelection({segments:[{id:"one",episodeId:"ep_test",start:0,end:10,reason:"A complete question and reaction"}]});
|
||||
expect(selected.selection?.reviewed).toBe(false);
|
||||
expect(JSON.stringify(selected)).not.toContain(root);
|
||||
await expect(workflow.review("selection",{planHash:selected.selection?.hash,pass:true})).rejects.toThrow();
|
||||
await expect(workflow.render(new AbortController().signal)).rejects.toMatchObject({code:"PLAN_REVIEW_REQUIRED"});
|
||||
await expect(workflow.proposeSelection({segments:[{id:"one",episodeId:"foreign",start:0,end:10,reason:"Invalid source"}]})).rejects.toMatchObject({code:"SELECTION_INVALID"});
|
||||
}finally{await rm(root,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
it("binds human-reviewed selection to original validators and renders only a REVIEW artifact",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"workflow-render-"));const signal=new AbortController().signal;
|
||||
try{
|
||||
const runtime=await mediaRuntime();const source=join(root,"source.mp4");
|
||||
await runMedia(runtime.ffmpeg,["-v","error","-f","lavfi","-i","testsrc2=size=180x320:rate=24","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","11","-c:v","libx264","-c:a","aac",source],root,signal);
|
||||
await writeFile(join(root,"target-drama-analysis.json"),JSON.stringify(analysisFixture()));
|
||||
const original=await readFile(source);
|
||||
const workflow=new EditorWorkflow({root,sessionId:"ses_test",jobId:"job_test",analysisId:"analysis_fixture",dramaId:"drama_fixture",sources:[{episodeId:"ep_test",episodeNo:1,source,duration:11,sha256:createHash("sha256").update(original).digest("hex"),subtitle:"WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nSynthetic test caption.\n"}],runner:new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")})});
|
||||
const selected=await workflow.proposeSelection({segments:[{id:"one",episodeId:"ep_test",start:0,end:10,reason:"Synthetic complete test sequence"}]});
|
||||
const evidence="Synthetic engineering review; not a licensed drama clearance.";
|
||||
await workflow.review("selection",{planHash:selected.selection!.hash,reviewer:"Test fixture",policyCheckedAt:new Date().toISOString(),policyPageUpdated:"Test fixture policy",pictureAudioReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",pictureEvidence:evidence,audioEvidence:evidence,subtitleEvidence:evidence}]});
|
||||
const unverified=planFixture();unverified.referenceCandidates.candidates[0]!.evidence.metrics={views:0,likes:0};
|
||||
const pendingReference=await workflow.submitPlan(unverified,signal);
|
||||
expect(pendingReference.pass).toBe(true);
|
||||
await expect(workflow.review("plan",{planHash:workflow.view().plan!.hash,reviewer:"Test fixture",policyCheckedAt:new Date().toISOString(),pictureAudioReviewed:true,referenceReviewed:true,storyReviewed:true,packagingReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",observation:evidence}]})).rejects.toMatchObject({code:"VERIFIED_REFERENCE_REQUIRED"});
|
||||
const result=await workflow.submitPlan(planFixture(),signal);
|
||||
expect(result.pass).toBe(true);
|
||||
const plan=workflow.view().plan!;
|
||||
await expect(workflow.render(signal)).rejects.toMatchObject({code:"PLAN_REVIEW_REQUIRED"});
|
||||
await workflow.review("plan",{planHash:plan.hash,reviewer:"Test fixture",policyCheckedAt:new Date().toISOString(),pictureAudioReviewed:true,referenceReviewed:true,storyReviewed:true,packagingReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",observation:evidence}]});
|
||||
await writeFile(source,"changed after review");
|
||||
await expect(workflow.render(signal)).rejects.toMatchObject({code:"REVIEWED_SOURCE_CHANGED"});
|
||||
await writeFile(source,original);
|
||||
const rendered=await workflow.render(signal);
|
||||
expect(rendered.preview?.duration).toBeCloseTo(10,1);expect(rendered.preview?.reviewStatus).toBe("REVIEW");
|
||||
expect(JSON.stringify(rendered)).not.toContain(root);
|
||||
const bytes=await readFile(workflow.previewPath(plan.id));expect(bytes.byteLength).toBeGreaterThan(1000);
|
||||
expect(rendered.humanReviewPassed).toBe(false);
|
||||
}finally{await rm(root,{recursive:true,force:true});}
|
||||
},60_000);
|
||||
@@ -0,0 +1,9 @@
|
||||
// Synthetic engineering fixture, never used as a real drama diagnosis.
|
||||
export function analysisFixture(){return {
|
||||
schema_version:"2.0",analysis_id:"analysis_fixture",analysis_status:"ready",
|
||||
drama:{drama_id:"drama_fixture",title:"Synthetic validation fixture",synopsis:"Test-only conflict with an unresolved question.",tags:["Test"],episode_count:3},cms_source:{fetched_at:"2026-09-14T00:00:00Z"},
|
||||
genre:{primary:"family evidence dispute mystery",secondary:["family conflict"],anti_genres:["comedy","romantic payoff"],reason:"Synthetic test only",evidence:[{source_type:"synopsis",claim:"Fixture synopsis"},{source_type:"subtitle",claim:"Fixture subtitle"},{source_type:"frame",claim:"Fixture frame"}]},
|
||||
core_promise:"Resolve the fixture dispute",primary_hook:{question:"Who left the test note?",promise:"An unresolved dispute"},relationship_conflict:{protagonist:"A",opposition:"B",surface_conflict:"A note",deeper_conflict:"Trust"},character_engine:{misery:["Uncertainty"],power:["Search"],desire:"An answer"},emotional_pattern:["doubt","discovery","uncertainty"],
|
||||
available_story_beats:[1,2,3].map(episode=>({episode,time_ranges:["00:00:00-00:00:10"],beat:"Synthetic test"})),
|
||||
evidence:{synopsis_used:true,synopsis_source:"Fixture",subtitle_evidence:["opening","middle","late"].map((story_position,index)=>({episode:index+1,story_position,time_range:"00:00:00-00:00:10",claim:"Synthetic"})),frame_evidence:[{episode:1,time_range:"00:00:01",claim:"Synthetic pattern"},{episode:2,time_range:"00:00:02",claim:"Synthetic pattern"}],conflicts:[],conflicts_resolved:true},must_explain:["The note"],withheld_information:["The author"],confidence:0.8,
|
||||
};}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Synthetic data for isolated engineering tests only. Not production reference evidence.
|
||||
export function planFixture(){
|
||||
const binding={analysis_id:"analysis_fixture",drama_id:"drama_fixture"};
|
||||
const shared={target_binding:binding,withheld_information:["The author"]};
|
||||
const questions=["Who left this test note?","Why was the test note hidden?","Will the note resolve this dispute?","How will they find the author?"];
|
||||
return {
|
||||
recipe:{schema_version:"2.0",...binding,duration_strategy:"content_led",withheld_information:shared.withheld_information,reference_id:"ref_fixture",reference_evidence_tier:"A",ending:{cutoff_reason:"Synthetic ending with reaction",before_withheld_reveal:true},segments:[{id:"one",hook_id:"hook_1",episode_id:"ep_test",episode:1,source_start:0,source_end:10,target_start:0,target_end:10,semantic_role:"Synthetic question",continuity_group:"scene",source_gap_before:0,confidence:"high",reveal_risk:"low",cut_reason:"Test unit complete"}]},
|
||||
hookMap:{...shared,mainline_question:questions[0],beats:["beat","phase","mainline"].map((hook_level,index)=>({id:`hook_${String(index+1)}`,target_start:index*3,target_end:index===2?10:(index+1)*3,hook_level,audience_question_before:questions[index],information_revealed:["A test clue"],revealed_categories:["evidence"],question_resolved:"A clue exists",new_question:questions[index+1],expectation_a:null,actual_b:null,hook_c:null,withheld_information:shared.withheld_information,evidence:"Synthetic span"})),phase_hooks:[{id:"phase",beat_ids:["hook_1","hook_2"],stage_payoff:"The clue was found",escalated_question:questions[2]}],final_hook:{beat_id:"hook_3",cutoff_after_complete_action:true,reaction_retained:true,answer_withheld:true},final_unanswered_question:questions[3]},
|
||||
strategy:{...shared,narrative_structure:{mode:"linear",reason:"Synthetic causal sequence for engineering verification"},opening:{mode:"original_footage",voiceover_authorized:false},first_30_seconds:{central_promise:"Test clue"},character_amplification:{primary:"power",evidence:["Fixture"]},event_density:{major_events:["Test clue"]}},
|
||||
referenceCandidates:{candidates:[{id:"ref_fixture",platform:"Synthetic",reference_type:"short_drama_ad",origin_name:"Test-only publisher",origin_evidence_url:"https://example.com/title",playable_url:"https://example.com/video",evidence_url:"https://example.com/evidence",captured_at:new Date().toISOString(),full_video_access:true,evidence:{metrics:{views:10,likes:1}},genre_match:1,hook_match:1,replicability:1,market_recency:1}]},
|
||||
referenceAnalysis:{...shared,reference_id:"ref_fixture",structure:"Synthetic question then partial answer"},
|
||||
referenceRecipe:{...shared,reference_id:"ref_fixture",beats:[{role:"Synthetic question"}]},
|
||||
continuity:{...shared,checks:["Synthetic continuous shot"],findings:[]},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHls, resolveHlsAsset } from "../src/media/hls.js";
|
||||
|
||||
describe("bounded HLS inputs", () => {
|
||||
it("preserves CDN-issued per-path signatures without accepting unrelated parameters", () => {
|
||||
const base = new URL("https://cdn.example/episode/master.m3u8?auth_key=123-0-0-abcdef");
|
||||
expect(resolveHlsAsset(base, "child.m3u8?auth_key=124-0-0-123abc", "/episode/").search).toBe("?auth_key=124-0-0-123abc");
|
||||
for (const uri of ["child.m3u8?auth_key=124-0-0-123abc&token=secret", "child.m3u8?auth_key=oops", "child.m3u8?auth_key=124-0-0-123abc&auth_key=124-0-0-123abc"]) expect(() => resolveHlsAsset(base, uri, "/episode/")).toThrow();
|
||||
});
|
||||
it("parses finite TS playlists and preserves exact durations", () => {
|
||||
expect(parseHls("#EXTM3U\n#EXTINF:4.5,\none.ts\n#EXTINF:2,\ntwo.ts\n#EXT-X-ENDLIST\n")).toEqual({kind:"media",segments:[{uri:"one.ts",duration:4.5},{uri:"two.ts",duration:2}],duration:6.5});
|
||||
});
|
||||
it("rejects incomplete, encrypted and unsupported source structures", () => {
|
||||
for (const text of ["not hls", "#EXTM3U\n#EXTINF:3,\none.ts", "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n#EXTINF:3,\none.ts\n#EXT-X-ENDLIST", "#EXTM3U\n#EXT-X-MAP:URI=init.mp4\n#EXTINF:3,\none.m4s\n#EXT-X-ENDLIST", "#EXTM3U\n#EXTINF:-3,\none.ts\n#EXT-X-ENDLIST"]) expect(()=>parseHls(text)).toThrow();
|
||||
});
|
||||
it("recognizes a simple master but refuses separate audio renditions", () => {
|
||||
expect(parseHls("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=400000\nlow/list.m3u8")).toEqual({kind:"master",variants:["low/list.m3u8"]});
|
||||
expect(()=>parseHls('#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,URI="audio.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=1\nvideo.m3u8')).toThrow();
|
||||
});
|
||||
it("keeps signing parameters only within the signed episode's path and origin", () => {
|
||||
const base = new URL("https://cdn.example/video/episode/master.m3u8?auth_key=fixture");
|
||||
expect(resolveHlsAsset(base,"low/0001.ts",base.pathname.slice(0,base.pathname.lastIndexOf('/')+1)).href).toBe("https://cdn.example/video/episode/low/0001.ts?auth_key=fixture");
|
||||
for (const uri of ["https://attacker.example/a.ts","../other.ts","file:///tmp/a.ts","//127.0.0.1/a.ts","%2e%2e/other.ts","one.ts?token=other"]) expect(()=>resolveHlsAsset(base,uri,"/video/episode/")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as media from "../src/image-video/media.js";
|
||||
import { mediaRuntime, runMedia } from "../src/media/media-worker.js";
|
||||
|
||||
it("trims H3's small frame overrun to the requested duration at original speed, preserving the source and rejecting short or excessive output", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "i2v-duration-"));
|
||||
const { ffmpeg, ffprobe } = await mediaRuntime();
|
||||
const signal = new AbortController().signal;
|
||||
const make = async (name: string, seconds: string) => {
|
||||
const path = join(directory, name);
|
||||
await runMedia(ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "testsrc2=size=90x160:rate=24", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=32000", "-t", seconds, "-c:v", "libx264", "-preset", "ultrafast", "-c:a", "aac", "-n", path], directory, signal);
|
||||
return path;
|
||||
};
|
||||
const source = await make("original.mp4", "1.125");
|
||||
const original = await readFile(source);
|
||||
const output = await media.conformImageVideoDuration(source, directory, 1);
|
||||
const probe = JSON.parse(await runMedia(ffprobe, ["-v", "error", "-show_streams", "-of", "json", output], directory, signal)) as { streams: { codec_type: string; duration: string; nb_frames: string; avg_frame_rate: string }[] };
|
||||
const video = probe.streams.find(s => s.codec_type === "video")!;
|
||||
expect(Number(video.duration)).toBe(1);
|
||||
expect(video.nb_frames).toBe("24");
|
||||
expect(video.avg_frame_rate).toBe("24/1");
|
||||
expect(Number(probe.streams.find(s => s.codec_type === "audio")!.duration)).toBeCloseTo(1, 2);
|
||||
expect(await readFile(source)).toEqual(original);
|
||||
// Actual H3 2-second response is 56 frames (17k+5), not 48 frames.
|
||||
const gridSource = await make("h3-2s.mp4", "2.333333");
|
||||
const gridOriginal = await readFile(gridSource);
|
||||
const gridOutput = await media.conformImageVideoDuration(gridSource, directory, 2);
|
||||
const gridProbe = JSON.parse(await runMedia(ffprobe, ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration,nb_frames,avg_frame_rate", "-of", "json", gridOutput], directory, signal)) as { streams: { duration: string; nb_frames: string; avg_frame_rate: string }[] };
|
||||
expect(gridProbe.streams[0]).toMatchObject({ duration: "2.000000", nb_frames: "48", avg_frame_rate: "24/1" });
|
||||
expect(await readFile(gridSource)).toEqual(gridOriginal);
|
||||
await expect(media.conformImageVideoDuration(output, directory, 1)).resolves.toBe(output);
|
||||
await expect(media.conformImageVideoDuration(await make("short.mp4", "0.875"), directory, 1)).rejects.toBeDefined();
|
||||
await expect(media.conformImageVideoDuration(await make("long.mp4", "1.5"), directory, 1)).rejects.toBeDefined();
|
||||
}, 30_000);
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { ImageVideoMcpRemote, imageVideoResponse } from "../src/image-video/mcp.js";
|
||||
import type { ImageVideoRecord } from "../src/image-video/contracts.js";
|
||||
import { mkdtemp, mkdir, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ lookup: () => Promise.resolve([{ address: "8.8.8.8", family: 4 }]) }));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("accepts the live reused-image receipt only when hash, type and size match the frozen reference", async () => {
|
||||
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: Object.fromEntries(["tenant_id", "platform", "platforms", "media_kind", "review_status", "asset_role", "ratio", "prompt", "file_name", "content_type", "size_bytes", "content_sha256"].map(key => [key, {}])) } })) });
|
||||
const file = { file_id: "file_1", filename: "i2v_test.jpeg", stored_path: "https://cdn.example/image.jpeg", object_key: "image.jpeg", asset_url: "https://cdn.example/image.jpeg", mime_type: "image/jpeg", size_bytes: 9, attachment_kind: "image", creative_factory_asset_id: "existing_asset", creative_factory_request_id: "request_1", asset_metadata: { content_sha256: "a".repeat(64) } };
|
||||
let reusedFile: unknown = file;
|
||||
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => Promise.resolve({ content: [], structuredContent: request.name === "swads_whoami" ? { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } } : { upload_id: "du-reuse-1", object_key: "image.jpeg", upload_url: "", public_url: "https://cdn.example/image.jpeg", method: "PUT", headers: {}, expires_in_seconds: 0, reused: true, reused_file: reusedFile } }));
|
||||
const remote = new ImageVideoMcpRemote({ token: "test" });
|
||||
const record = { id: "i2v_test", identity: { tenantId: "tenant", platform: "tiktok" }, draft: { prompt: "waves" }, referenceType: "image/jpeg", referenceHash: "a".repeat(64) } as ImageVideoRecord;
|
||||
await expect(remote.initialize(record, Buffer.from("reference"))).resolves.toEqual({ reused: true, assetId: "existing_asset" });
|
||||
for (const changed of [{ asset_metadata: { content_sha256: "b".repeat(64) } }, { mime_type: "image/png" }, { size_bytes: 10 }, { creative_factory_asset_id: "" }]) {
|
||||
reusedFile = { ...file, ...changed };
|
||||
await expect(remote.initialize(record, Buffer.from("reference"))).rejects.toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("collects the registered job's storage_url MP4 and ignores JSON artifacts, rejecting foreign or conflicting receipts", async () => {
|
||||
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: { job_id: {} } } })) });
|
||||
const video = { artifact_id: "artifact_video", job_id: "job_1", org_id: "org", asset_id: null, version_id: null, artifact_kind: "video", content_type: "video/mp4", storage_url: "https://cdn.example/video.mp4", object_key: "video.mp4", json_payload: null, metadata: {}, created_at: "2026-09-15T00:00:00Z" };
|
||||
let result: Record<string, unknown> = { job_id: "job_1", status: "succeeded", artifacts: [video, { ...video, artifact_id: "artifact_json", artifact_kind: "metadata", content_type: "application/json", storage_url: null, object_key: null, json_payload: { provider: "h3" } }] };
|
||||
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => Promise.resolve({ content: [], structuredContent: request.name === "swads_whoami" ? { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } } : result }));
|
||||
const remote = new ImageVideoMcpRemote({ token: "test" });
|
||||
const record = { jobId: "job_1" } as ImageVideoRecord;
|
||||
await expect(remote.status(record)).resolves.toEqual({ state: "succeeded", artifactUrl: "https://cdn.example/video.mp4" });
|
||||
result = { job_id: "job_1", status: "succeeded", artifacts: [{ ...video, job_id: "job_other" }] };
|
||||
await expect(remote.status(record)).rejects.toBeDefined();
|
||||
result = { job_id: "job_1", status: "succeeded", artifacts: [{ ...video, url: "https://cdn.example/other.mp4" }] };
|
||||
await expect(remote.status(record)).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("submits within the gateway key budget and deduplicates the same frozen request without merging different requests", async () => {
|
||||
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: Object.fromEntries(["prompt", "title", "reference_asset_ids", "aspect_ratio", "duration_seconds", "resolution", "h3_mode", "idempotency_key", "skill_id"].map(key => [key, {}])) } })) });
|
||||
const jobs = new Map<string, string>();
|
||||
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => {
|
||||
if (request.name === "swads_whoami") return Promise.resolve({ content: [], structuredContent: { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } } });
|
||||
const key = String(request.arguments?.idempotency_key);
|
||||
if (key.length > 87) return Promise.resolve({ content: [], structuredContent: { error_code: "invalid_arguments", detail: "idempotency_key must be at most 87 characters" } });
|
||||
if (!jobs.has(key)) jobs.set(key, `job_${jobs.size + 1}`);
|
||||
return Promise.resolve({ content: [], structuredContent: { job_id: jobs.get(key) } });
|
||||
});
|
||||
const remote = new ImageVideoMcpRemote({ token: "test" });
|
||||
const record = { id: "i2v_11111111-1111-1111-1111-111111111111", requestHash: "a".repeat(64), draft: { prompt: "waves", title: "test", durationSeconds: 10 }, assetId: "asset_1" } as ImageVideoRecord;
|
||||
await expect(remote.submit(record)).resolves.toEqual({ jobId: "job_1" });
|
||||
await expect(remote.submit(record)).resolves.toEqual({ jobId: "job_1" });
|
||||
await expect(remote.submit({ ...record, requestHash: "b".repeat(64) })).resolves.toEqual({ jobId: "job_2" });
|
||||
await expect(remote.submit({ ...record, id: "i2v_22222222-2222-2222-2222-222222222222" })).resolves.toEqual({ jobId: "job_3" });
|
||||
});
|
||||
|
||||
it("uploads with the frozen prompt and ratio required by the live begin/finish contract", async () => {
|
||||
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
|
||||
const properties = Object.fromEntries(["tenant_id", "platform", "platforms", "media_kind", "review_status", "asset_role", "ratio", "prompt", "file_name", "content_type", "size_bytes", "content_sha256", "upload_id", "object_key"].map(key => [key, {}]));
|
||||
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties } })) });
|
||||
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => {
|
||||
const args = request.arguments;
|
||||
const result = request.name === "swads_whoami" ? { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } }
|
||||
: args?.ratio !== "9:16" || args.prompt !== "Subtle waves and breeze" ? { error_code: "invalid_arguments", detail: "push begin needs: ratio, prompt" }
|
||||
: args.file_name !== "i2v_test.jpeg" || args.content_type !== "image/jpeg" || args.size_bytes !== 9 ? { error_code: "invalid_arguments", detail: "push finish needs: file_name, content_type, size_bytes" }
|
||||
: args.upload_id ? { status: "finalizing" } : { upload_id: "upload_1", object_key: "image.jpeg", upload_url: "https://public.example/put?signature=private" };
|
||||
return Promise.resolve({ content: [{ type: "text", text: JSON.stringify(result) }] });
|
||||
});
|
||||
const remote = new ImageVideoMcpRemote({ token: "test" });
|
||||
const record = { id: "i2v_test", identity: { tenantId: "tenant", platform: "tiktok" }, draft: { prompt: "Subtle waves and breeze" }, referenceType: "image/jpeg", referenceHash: "a".repeat(64) } as ImageVideoRecord;
|
||||
const upload = await remote.initialize(record, Buffer.from("reference"));
|
||||
if ("reused" in upload) throw new Error("Expected a new upload ticket");
|
||||
expect(upload).toEqual({ uploadId: "upload_1", objectKey: "image.jpeg", uploadUrl: "https://public.example/put?signature=private" });
|
||||
await expect(remote.finalize({ ...record, upload }, Buffer.from("reference"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes error_code failures even when MCP isError is false without exposing remote details", () => {
|
||||
expect(() => imageVideoResponse({ isError: false, content: [{ type: "text", text: JSON.stringify({ error_code: "invalid_arguments", detail: "token=private https://private.example/?signature=secret" }) }] })).toThrow(expect.objectContaining({ code: "I2V_REMOTE_REJECTED", message: "SW Ads rejected the request (invalid_arguments)" }));
|
||||
});
|
||||
|
||||
it("retains an unparseable submission response privately before rejecting it, without a second submit", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "i2v-receipt-"));
|
||||
const id = "i2v_11111111-1111-1111-1111-111111111111";
|
||||
await mkdir(join(root, id), { mode: 0o700 });
|
||||
vi.spyOn(Client.prototype, "connect").mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, "listTools").mockResolvedValue({ tools: ["swads_whoami", "creative_upload_from_url", "creative_import_status", "video_h3_submit", "video_job_status"].map(name => ({ name, inputSchema: { type: "object", properties: Object.fromEntries(["prompt", "title", "reference_asset_ids", "aspect_ratio", "duration_seconds", "resolution", "h3_mode", "idempotency_key", "skill_id"].map(key => [key, {}])) } })) });
|
||||
let submissions = 0;
|
||||
const raw = { content: [{ type: "text" as const, text: '{"unexpected_job":{"id":"recoverable_job"},"private_url":"https://public.example/?signature=secret"}' }] };
|
||||
vi.spyOn(Client.prototype, "callTool").mockImplementation(request => {
|
||||
if (request.name === "swads_whoami") return Promise.resolve({ structuredContent: { access: { current_account: { tenant_id: "tenant", platform: "tiktok" } } }, content: [] });
|
||||
submissions++; return Promise.resolve(raw);
|
||||
});
|
||||
const remote = new ImageVideoMcpRemote({ token: "private-token", receiptRoot: root });
|
||||
const record = { id, revision: 4, requestHash: "a".repeat(64), draft: { prompt: "waves", title: "test", durationSeconds: 10 }, assetId: "asset_1" } as ImageVideoRecord;
|
||||
await expect(remote.submit(record)).rejects.toBeDefined();
|
||||
const files = await readdir(join(root, id));
|
||||
expect(files).toHaveLength(1);
|
||||
const file = join(root, id, files[0]!);
|
||||
expect(JSON.parse(await readFile(file, "utf8"))).toMatchObject({ id, revision: 4, requestHash: "a".repeat(64), operation: "submit", response: raw });
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||
expect(await readFile(file, "utf8")).not.toContain("private-token");
|
||||
expect(submissions).toBe(1);
|
||||
});
|
||||
@@ -29,6 +29,62 @@ async function fixture() {
|
||||
}
|
||||
|
||||
describe("durable image video tasks", () => {
|
||||
it("continues an orphaned uploaded task locally without losing receipts or automatically submitting", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
|
||||
const path = join(f.options.root, task.id, "record.json");
|
||||
const before = JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord;
|
||||
f.calls.length = 0;
|
||||
const restored = new harness.ImageVideoService(f.options);
|
||||
const result = await restored.continueTask("ses_new", task.id, before.revision, () => false, () => {});
|
||||
expect(result).toMatchObject({ id: task.id, state: "awaiting_submission", revision: before.revision + 1 });
|
||||
expect(f.calls).toEqual([]);
|
||||
const after = JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord;
|
||||
expect(after).toMatchObject({ ownerSessionId: "ses_new", draft: before.draft, referenceHash: before.referenceHash, requestHash: before.requestHash, upload: before.upload, assetId: "asset_1" });
|
||||
expect(after.continuations).toHaveLength(1);
|
||||
expect(after.continuations?.[0]).toMatchObject({ fromSessionId: "ses_owner", toSessionId: "ses_new", revision: before.revision });
|
||||
expect(Number.isFinite(Date.parse(after.continuations?.[0]?.at ?? ""))).toBe(true);
|
||||
await expect(restored.owned("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_TASK_NOT_FOUND" });
|
||||
expect((await restored.owned("ses_new", task.id)).id).toBe(task.id);
|
||||
await restored.submit("ses_new", task.id);
|
||||
expect(f.calls).toEqual(["identity", "submit"]);
|
||||
expect(JSON.stringify(result)).not.toMatch(/ownerSessionId|continuations|uploadUrl/);
|
||||
});
|
||||
it("rejects active-owner takeover, stale confirmation, uncertain state and concurrent continuation", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await expect(f.service.continueTask("ses_new", task.id, 0, () => true, () => {})).rejects.toMatchObject({ code: "I2V_OWNER_ACTIVE" });
|
||||
await expect(f.service.continueTask("ses_new", task.id, 99, () => false, () => {})).rejects.toMatchObject({ code: "I2V_STALE_REVISION" });
|
||||
const attempts = await Promise.allSettled([f.service.continueTask("ses_new", task.id, 0, () => false, () => {}), f.service.continueTask("ses_other", task.id, 0, () => false, () => {})]);
|
||||
expect(attempts.map(a => a.status)).toEqual(["fulfilled", "rejected"]);
|
||||
const path = join(f.options.root, task.id, "record.json");
|
||||
const record = JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord;
|
||||
for (const state of ["unknown_upload", "unknown_submission", "failed", "queued"] as const) {
|
||||
await writeFile(path, JSON.stringify({ ...record, state }));
|
||||
await expect(f.service.continueTask("ses_new", task.id, record.revision, () => false, () => {})).rejects.toMatchObject({ code: "I2V_CONTINUE_BLOCKED" });
|
||||
expect((JSON.parse(await readFile(path, "utf8")) as ImageVideoRecord).state).toBe(state);
|
||||
}
|
||||
expect(f.calls).toEqual([]);
|
||||
});
|
||||
it("reuses a verified existing image without PUT, finalize or automatic generation and persists it across restart", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
f.remote.initialize = () => { f.calls.push("initialize"); return Promise.resolve({ reused: true as const, assetId: "existing_asset" }); };
|
||||
expect((await f.service.upload("ses_owner", task.id)).state).toBe("awaiting_submission");
|
||||
expect(f.calls).toEqual(["identity", "initialize"]);
|
||||
const restored = new harness.ImageVideoService(f.options);
|
||||
expect((await restored.view(task.id)).state).toBe("awaiting_submission");
|
||||
expect((await restored.submit("ses_owner", task.id)).jobId).toBe("remote_job_1");
|
||||
expect(f.calls).not.toContain("put"); expect(f.calls).not.toContain("finalize");
|
||||
});
|
||||
it("persists a sanitized failure stage while keeping rejected uploads quarantined", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
f.remote.initialize = () => Promise.reject(new harness.HarnessError("I2V_REMOTE_REJECTED", "secret signed URL"));
|
||||
const result = await f.service.upload("ses_owner", task.id);
|
||||
expect(result).toMatchObject({ state: "unknown_upload" });
|
||||
expect(result.error).toContain("initializing_upload/I2V_REMOTE_REJECTED");
|
||||
expect(result.error).not.toContain("secret");
|
||||
expect((await new harness.ImageVideoService(f.options).view(task.id)).error).toBe(result.error);
|
||||
expect(f.calls).not.toContain("put");
|
||||
});
|
||||
it("copies a local reference without transmitting it and hides private bindings", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
expect(f.calls).toEqual([]); expect(task.state).toBe("draft");
|
||||
@@ -65,6 +121,16 @@ describe("durable image video tasks", () => {
|
||||
record.state = "submitting"; await writeFile(file, JSON.stringify(record));
|
||||
expect((await new harness.ImageVideoService(f.options).view(task.id)).state).toBe("unknown_submission");
|
||||
});
|
||||
it("records a typed submission rejection without exposing provider details or retrying", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
await f.service.upload("ses_owner", task.id); await f.service.refresh(task.id);
|
||||
f.remote.submit = () => Promise.reject(new harness.HarnessError("I2V_REMOTE_REJECTED", "secret upstream detail"));
|
||||
const result = await f.service.submit("ses_owner", task.id);
|
||||
expect(result.error).toContain("submitting/I2V_REMOTE_REJECTED");
|
||||
expect(result.error).not.toContain("secret");
|
||||
expect(result.state).toBe("unknown_submission");
|
||||
await expect(f.service.submit("ses_owner", task.id)).rejects.toMatchObject({ code: "I2V_UNKNOWN_SUBMISSION" });
|
||||
});
|
||||
it("never reinitializes uncertain uploads and blocks stale persisted revisions", async () => {
|
||||
const f = await fixture(); const task = await f.service.create("ses_owner", f.draft);
|
||||
f.remote.initialize = () => { f.calls.push("initialize"); return Promise.reject(new Error("timeout")); };
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { downloadHls } from "../src/media/media-worker.js";
|
||||
|
||||
const roots:string[]=[];
|
||||
afterEach(async()=>{for(const root of roots.splice(0)) await rm(root,{recursive:true,force:true});});
|
||||
describe("private source download",()=>{
|
||||
it("localizes a signed VOD playlist without leaking credentials to disk",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"hls-test-"));roots.push(root);
|
||||
const requests:URL[]=[];
|
||||
const fetcher:typeof fetch=(input,init)=>{
|
||||
const url=new URL(typeof input==="string"?input:input instanceof URL?input.href:input.url);requests.push(url);
|
||||
expect(init?.redirect).toBe("error");expect(new Headers(init?.headers).has("authorization")).toBe(false);
|
||||
return Promise.resolve(new Response(url.pathname.endsWith(".m3u8")?"#EXTM3U\n#EXTINF:2,\n0.ts\n#EXT-X-ENDLIST\n":new Uint8Array([0x47,0,0,0])));
|
||||
};
|
||||
const result=await downloadHls(new URL("https://video.pencil-stub.com/episode/master.m3u8?auth_key=private"),root,new AbortController().signal,{remaining:1024},fetcher);
|
||||
expect(result.duration).toBe(2);
|
||||
const playlist=await readFile(result.path,"utf8");
|
||||
expect(playlist).toContain("segment-0.ts");expect(playlist).not.toMatch(/private|https:|auth_key/);
|
||||
expect(requests.every(url=>url.origin==="https://video.pencil-stub.com"&&url.search==="?auth_key=private")).toBe(true);
|
||||
});
|
||||
it("enforces byte quota before persisting oversized segments",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"hls-test-"));roots.push(root);
|
||||
await expect(downloadHls(new URL("https://video.pencil-stub.com/e/a.m3u8"),root,new AbortController().signal,{remaining:1},()=>Promise.resolve(new Response("#EXTM3U\n#EXTINF:2,\na.ts\n#EXT-X-ENDLIST")))).rejects.toMatchObject({code:"MEDIA_BYTE_LIMIT"});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import * as harness from "../src/index.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
|
||||
|
||||
describe("Minis report service", () => {
|
||||
it("requires captured query evidence and returns real HTML with an explicit PDF failure", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "minis-report-test-")); roots.push(root);
|
||||
const skill = join(root, "skill");
|
||||
await mkdir(join(skill, "scripts"), { recursive: true });
|
||||
await writeFile(join(skill, "scripts/generate_report.py"), 'import sys,json,html\nfrom pathlib import Path\nd=json.loads(Path(sys.argv[1]).read_text())\nPath(sys.argv[2]).write_text("<!doctype html><h1>"+html.escape(d["title"])+"</h1>")\n');
|
||||
const Constructor = Reflect.get(harness, "MinisReportService");
|
||||
expect(Constructor).toBeDefined();
|
||||
const service = new Constructor({ workspace: await harness.SafeWorkspace.create(root), sessionId: "ses_test", skillRoot: skill, chromeExecutable: "/missing/chrome", pythonExecutable: "/usr/bin/python3" });
|
||||
const data = { title: "渲染验收(人工测试数据)", report_type: "daily", period: "2026-09-01", comparison_period: "2026-08-31", currency: "USD", timezone: "UTC", data_status: "测试", conclusion: "仅测试排版,不是经营数据", cards: [], issues: [] };
|
||||
await expect(service.generate({ data, source_query_ids: [] })).rejects.toThrow("query");
|
||||
const queryId = service.recordQuery({ date_from: "2026-09-01" }, { content: [{ type: "text", text: "test-only" }] });
|
||||
await expect(service.generate({ data, source_query_ids: ["unknown"] })).rejects.toThrow("query");
|
||||
const output = await service.generate({ data, source_query_ids: [queryId] });
|
||||
expect(output.pdfStatus).toBe("failed");
|
||||
expect(output.files.map((f: {format: string}) => f.format)).toEqual(["json", "html"]);
|
||||
expect(output.files[1]?.url).toMatch(/^\/api\/sessions\/ses_test\/reports\/rpt_[\w-]+\/html$/);
|
||||
const html = await service.read(output.reportId, "html");
|
||||
expect(html.bytes.toString()).toContain("人工测试数据");
|
||||
await expect(service.read(output.reportId, "../../.env")).rejects.toThrow();
|
||||
const json = JSON.parse((await service.read(output.reportId, "json")).bytes.toString()) as { source_queries: Array<{queryId: string}> };
|
||||
expect(json.source_queries[0]?.queryId).toBe(queryId);
|
||||
expect(await readFile(join(skill, "scripts/generate_report.py"), "utf8")).not.toContain("SWADS_MCP_TOKEN");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,45 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import * as harness from "../src/index.js";
|
||||
|
||||
describe("PiEventAdapter", () => {
|
||||
it.each([
|
||||
{ stopReason: "stop", content: [], code: "MODEL_EMPTY_RESPONSE" },
|
||||
{ stopReason: "stop", content: [{ type: "thinking", thinking: "private reasoning" }], code: "MODEL_EMPTY_RESPONSE" },
|
||||
{ stopReason: "length", content: [{ type: "text", text: "partial output" }], code: "MODEL_OUTPUT_LIMIT" },
|
||||
])("rejects incomplete assistant replies ($stopReason, $code)", async ({ stopReason, content, code }) => {
|
||||
const store = new harness.InMemoryEventStore();
|
||||
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
|
||||
await expect(adapter.observe({ type: "message_end", message: { role: "assistant", stopReason, content } })).rejects.toMatchObject({ code });
|
||||
expect(store.listAfter("ses_1").events).toHaveLength(0);
|
||||
});
|
||||
it("keeps licensed frame bytes and subtitles out of event history", async () => {
|
||||
const store = new harness.InMemoryEventStore();
|
||||
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
|
||||
await adapter.observe({ type: "tool_execution_end", toolName: "read_drama_frames", toolCallId: "call_1", isError: false,
|
||||
result: { content: [{ type: "image", data: "PRIVATE_IMAGE_BYTES", mimeType: "image/jpeg" }, { type: "text", text: "PRIVATE_SUBTITLES" }], details: { jobId: "job_1", imageCount: 9 } } });
|
||||
expect(store.listAfter("ses_1").events[0]?.payload).toMatchObject({ result: { jobId: "job_1", imageCount: 9 } });
|
||||
expect(JSON.stringify(store.listAfter("ses_1"))).not.toContain("PRIVATE_");
|
||||
});
|
||||
it("does not announce completion between tool turns or turn provider errors into success", async () => {
|
||||
const store = new harness.InMemoryEventStore();
|
||||
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
|
||||
await adapter.observe({ type: "message_end", message: { role: "assistant", stopReason: "toolUse" } });
|
||||
expect(store.listAfter("ses_1").events).toHaveLength(0);
|
||||
await expect(adapter.observe({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "provider secret" } })).rejects.toThrow("Model request failed");
|
||||
expect(store.listAfter("ses_1").events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("unwraps real Pi custom-tool details for the metric card", async () => {
|
||||
const store = new harness.InMemoryEventStore();
|
||||
const adapter = new harness.PiEventAdapter({ sessionId: "ses_1", runId: "run_1", eventStore: store });
|
||||
await adapter.observe({
|
||||
type: "tool_execution_end", toolName: "mock_ads_metrics", toolCallId: "call_1", isError: false,
|
||||
result: { content: [{ type: "text", text: '{"spend":120,"revenue":360,"roas":3}' }], details: { spend: 120, revenue: 360, roas: 3 } },
|
||||
});
|
||||
expect(store.listAfter("ses_1").events[0]?.payload).toEqual({
|
||||
toolCallId: "call_1", toolName: "mock_ads_metrics", result: { spend: 120, revenue: 360, roas: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps assistant deltas and completion with one stable message id", async () => {
|
||||
const store = new harness.InMemoryEventStore();
|
||||
const Adapter = Reflect.get(harness, "PiEventAdapter") as unknown as new (options: Record<string, unknown>) => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {describe,it,expect} from "vitest";
|
||||
import {bindRecipe} from "../src/media/recipe-binding.js";
|
||||
const source={episodeId:"episode_real",episodeNo:1,source:"/private/authorized/episode.mp4",duration:20};
|
||||
function recipe(){return {schema_version:"2.0",analysis_id:"analysis_real",drama_id:"drama_real",duration_strategy:"content_led",withheld_information:["An answer"],ending:{cutoff_reason:"Question remains",before_withheld_reveal:true},segments:[{id:"one",hook_id:"hook",episode_id:"episode_real",episode:1,source:"/etc/passwd",source_start:1,source_end:11,target_start:0,target_end:10,semantic_role:"Question",continuity_group:"scene",source_gap_before:0,confidence:"high",reveal_risk:"low",cut_reason:"Complete exchange"}]};}
|
||||
const binding={analysisId:"analysis_real",dramaId:"drama_real",analysisPath:"/private/job/target-drama-analysis.json",withheldInformation:["An answer"],sources:[source]};
|
||||
describe("authorized ten-second recipe",()=>{
|
||||
it("resolves source and analysis paths on the server instead of accepting model-supplied paths",()=>{
|
||||
const bound=bindRecipe(recipe(),binding);
|
||||
expect(bound.segments[0]?.source).toBe(source.source);expect(bound.target_analysis_path).toBe(binding.analysisPath);
|
||||
});
|
||||
it("rejects wrong identities, wrong episodes, out-of-source ranges and non-frame-aligned cuts",()=>{
|
||||
const foreign=recipe();foreign.drama_id="other";expect(()=>bindRecipe(foreign,binding)).toThrow();
|
||||
const episode=recipe();episode.segments[0]!.episode_id="other";expect(()=>bindRecipe(episode,binding)).toThrow();
|
||||
const range=recipe();range.segments[0]!.source_end=30;expect(()=>bindRecipe(range,binding)).toThrow();
|
||||
const boundary=recipe();boundary.segments[0]!.source_start=1.01;expect(()=>bindRecipe(boundary,binding)).toThrow();
|
||||
const reveal=recipe();reveal.withheld_information=["Different answer"];expect(()=>bindRecipe(reveal,binding)).toThrow();
|
||||
});
|
||||
it("rejects non-ten-second output and arbitrary subtitle input paths",()=>{
|
||||
const long=recipe();long.segments[0]!.source_end=12;long.segments[0]!.target_end=11;expect(()=>bindRecipe(long,binding)).toThrow();
|
||||
expect(()=>bindRecipe({...recipe(),subtitle_path:"/private/secret.srt"},binding)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import {describe,it,expect} from "vitest";
|
||||
import {assertSelectionReview} from "../src/media/review-ledger.js";
|
||||
const plan={hash:"abc123",segmentIds:["one"]};
|
||||
const review={planHash:"abc123",reviewer:"Local user",policyCheckedAt:new Date().toISOString(),pictureAudioReviewed:true,referenceReviewed:true,storyReviewed:true,packagingReviewed:true,noUnresolvedFindings:true,segmentEvidence:[{segmentId:"one",observation:"Reviewed the complete selected span with original audio; nonsexual conversation."}]};
|
||||
describe("selection review gate",()=>{
|
||||
it("requires every real review and exact immutable plan hash",()=>{
|
||||
expect(()=>assertSelectionReview(review,plan)).not.toThrow();
|
||||
for(const field of ["pictureAudioReviewed","referenceReviewed","storyReviewed","packagingReviewed","noUnresolvedFindings"])expect(()=>assertSelectionReview({...review,[field]:false},plan)).toThrow();
|
||||
expect(()=>assertSelectionReview({...review,planHash:"changed"},plan)).toThrow();
|
||||
expect(()=>assertSelectionReview({...review,segmentEvidence:[]},plan)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import {it,expect} from "vitest";
|
||||
import {mkdtemp,rm,writeFile} from "node:fs/promises";
|
||||
import {tmpdir,homedir} from "node:os";
|
||||
import {join} from "node:path";
|
||||
import {SkillRunner} from "../src/media/skill-runner.js";
|
||||
import {mediaRuntime,runMedia} from "../src/media/media-worker.js";
|
||||
import {analysisFixture} from "./fixtures/editor-analysis.js";
|
||||
|
||||
it.each(["analysis","recipe","hook"] as const)("runs the original %s validator and returns its real failures, never a caller PASS flag",async(kind)=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"original-validator-"));
|
||||
try{
|
||||
const runner=new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")});
|
||||
const input=join(root,"analysis.json");await writeFile(input,JSON.stringify({pass:true}));
|
||||
const result=await runner.validate(kind,input,root,new AbortController().signal);
|
||||
expect(result.pass).toBe(false);expect(result.issues.length).toBeGreaterThan(0);
|
||||
expect(JSON.stringify(result)).not.toContain(root);
|
||||
}finally{await rm(root,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
it("keeps references blocked when only unverified evidence is supplied",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"original-reference-"));
|
||||
try{
|
||||
const analysis=join(root,"target-drama-analysis.json");await writeFile(analysis,JSON.stringify(analysisFixture()));
|
||||
const input=join(root,"candidates.json");await writeFile(input,JSON.stringify({target_analysis_path:analysis,analysis_id:"analysis_fixture",drama_id:"drama_fixture",candidates:[{id:"r1",full_video_access:true,reference_type:"short_drama_ad",origin_name:"Fixture",origin_evidence_url:"https://example.com/title",playable_url:"https://example.com/video",evidence_url:"https://example.com/metrics",platform:"fixture",captured_at:"2026-09-14T00:00:00Z",genre_match:1,hook_match:1,replicability:1,market_recency:1}]}));
|
||||
const runner=new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")});
|
||||
const result=await runner.scoreReferences(input,root,new AbortController().signal);
|
||||
expect(result.status).toBe("blocked_unverified_reference");expect(result.selected_reference).toBeNull();
|
||||
}finally{await rm(root,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
it("renders an actual 10-second H264/AAC file using the original renderer and reports actual QA",async()=>{
|
||||
const root=await mkdtemp(join(tmpdir(),"original-render-"));const signal=new AbortController().signal;
|
||||
try{
|
||||
const runtime=await mediaRuntime();const source=join(root,"source.mp4");
|
||||
await runMedia(runtime.ffmpeg,["-v","error","-f","lavfi","-i","testsrc2=size=180x320:rate=24","-f","lavfi","-i","sine=frequency=440:sample_rate=48000","-t","11","-c:v","libx264","-c:a","aac",source],root,signal);
|
||||
const analysis=join(root,"target-drama-analysis.json");await writeFile(analysis,JSON.stringify(analysisFixture()));
|
||||
const recipe=join(root,"recipe.json");await writeFile(recipe,JSON.stringify({schema_version:"2.0",analysis_id:"analysis_fixture",drama_id:"drama_fixture",target_analysis_path:analysis,withheld_information:["The author"],duration_strategy:"content_led",ending:{cutoff_reason:"Test",before_withheld_reveal:true},segments:[{id:"one",hook_id:"fixture",source,episode:1,source_start:0,source_end:10,target_start:0,target_end:10,semantic_role:"Test",continuity_group:"scene",source_gap_before:0,confidence:"high",reveal_risk:"low",cut_reason:"Synthetic test"}]}));
|
||||
const runner=new SkillRunner({skillRoot:join(homedir(),".codex/skills/swads-short-drama-editor"),pythonExecutable:join(homedir(),".cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3")});
|
||||
const result=await runner.render(recipe,root,signal);
|
||||
expect(result.duration).toBeCloseTo(10,1);expect(result.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(result.checks.complete_decode).toBe(true);expect(result.checks.h264_aac).toBe(true);expect(result.checks.portrait_1080x1920).toBe(true);expect(result.reviewStatus).toBe("REVIEW");
|
||||
}finally{await rm(root,{recursive:true,force:true});}
|
||||
},60_000);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
@@ -52,6 +52,61 @@ function catalogConstructor(): CatalogConstructor {
|
||||
}
|
||||
|
||||
describe("SkillCatalog", () => {
|
||||
it("keeps the editor's new content-review reference readable in the isolated Skill snapshot", async () => {
|
||||
const original = await createSkill(".agents/skills", "swads-short-drama-editor");
|
||||
const baseDir = join(root, ".agents/skills/swads-short-drama-editor");
|
||||
await mkdir(join(baseDir, "references"));
|
||||
await mkdir(join(baseDir, "scripts"));
|
||||
for (const resource of harness.EDITOR_RESOURCES) await writeFile(join(baseDir, resource), "fixture resource");
|
||||
await writeFile(join(baseDir, "references/tiktok-adult-content-policy.md"), "fixture selection and final review gate");
|
||||
const catalog = new harness.SkillCatalog({ cwd: root, agentDir, resources: { [original]: [...harness.EDITOR_RESOURCES] } });
|
||||
await catalog.reload();
|
||||
const sessionRoot = join(root, "session");
|
||||
await mkdir(sessionRoot);
|
||||
const snapshot = await catalog.createSessionSnapshot(sessionRoot);
|
||||
const editor = snapshot.skills.find((skill) => skill.name === "swads-short-drama-editor")!;
|
||||
const workspace = await harness.SafeWorkspace.create(sessionRoot);
|
||||
const policy = await workspace.resolveReadable(join(editor.baseDir, "references/tiktok-adult-content-policy.md"));
|
||||
expect(await readFile(policy, "utf8")).toBe("fixture selection and final review gate");
|
||||
});
|
||||
|
||||
it("copies only approved resources with relative links intact and rejects escaped resources", async () => {
|
||||
const original = await createSkill(".agents/skills", "diagnosis");
|
||||
const baseDir = join(root, ".agents/skills/diagnosis");
|
||||
await mkdir(join(baseDir, "references"));
|
||||
await writeFile(join(baseDir, "references/metrics.md"), "metric contract");
|
||||
await writeFile(join(baseDir, ".env"), "do not copy");
|
||||
const catalog = new harness.SkillCatalog({ cwd: root, agentDir,
|
||||
resources: { [original]: ["references/metrics.md"] },
|
||||
});
|
||||
await catalog.reload();
|
||||
const sessionRoot = join(root, "session");
|
||||
await mkdir(sessionRoot);
|
||||
const snapshot = await catalog.createSessionSnapshot(sessionRoot);
|
||||
const copied = snapshot.skills.find((skill) => skill.name === "diagnosis")!;
|
||||
expect(await readFile(join(copied.baseDir, "references/metrics.md"), "utf8")).toBe("metric contract");
|
||||
await expect(access(join(copied.baseDir, ".env"))).rejects.toThrow();
|
||||
await rm(join(baseDir, "references/metrics.md"));
|
||||
await symlink(join(baseDir, ".env"), join(baseDir, "references/metrics.md"));
|
||||
await expect(catalog.createSessionSnapshot(sessionRoot)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("makes discovered Skill bodies readable inside the Session without admitting project paths", async () => {
|
||||
const original = await createSkill(".agents/skills", "ads-analysis");
|
||||
const sessionRoot = join(root, "session");
|
||||
await mkdir(sessionRoot);
|
||||
const catalog = new harness.SkillCatalog({ cwd: root, agentDir });
|
||||
await catalog.reload();
|
||||
const snapshot = await catalog.createSessionSnapshot(sessionRoot);
|
||||
const workspace = await harness.SafeWorkspace.create(sessionRoot);
|
||||
const copied = snapshot.skills.find((skill) => skill.name === "ads-analysis")!;
|
||||
await expect(workspace.resolveReadable(copied.filePath)).resolves.toBe(copied.filePath);
|
||||
expect(await readFile(copied.filePath, "utf8")).toContain("Full secret skill body.");
|
||||
expect(snapshot.index.find(copied.filePath)?.name).toBe("ads-analysis");
|
||||
expect(snapshot.index.find(original)).toBeUndefined();
|
||||
await expect(workspace.resolveReadable(original)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("discovers .agents, .pi, and delimiter-separated extra roots through Pi", async () => {
|
||||
await createSkill(".agents/skills", "ads-analysis");
|
||||
await createSkill(".pi/skills", "report-writer");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as harness from "../src/index.js";
|
||||
|
||||
describe("swads MCP bridge", () => {
|
||||
it("performs MCP initialization, sends credentials only in headers and blocks non-allowlisted tools", async () => {
|
||||
const requests: Array<{ method: string; authorization: string | null }> = [];
|
||||
const fetcher: typeof fetch = async (_url, init) => {
|
||||
await Promise.resolve();
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.method === "GET") return new Response(null, { status: 405 });
|
||||
if (typeof init?.body !== "string") throw new Error("Expected JSON-RPC body");
|
||||
const message = JSON.parse(init.body) as { method: string; id?: number; params?: { name?: string } };
|
||||
requests.push({ method: message.method, authorization: headers.get("authorization") });
|
||||
if (message.id === undefined) return new Response(null, { status: 202 });
|
||||
const result = message.method === "initialize"
|
||||
? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "test", version: "1" } }
|
||||
: { content: [{ type: "text", text: '{"metric":"spend","value":7}' }], structuredContent: { metric: "spend", value: 7 } };
|
||||
return Response.json({ jsonrpc: "2.0", id: message.id, result });
|
||||
};
|
||||
const Constructor = Reflect.get(harness, "SwadsMcpClient");
|
||||
expect(Constructor).toBeDefined();
|
||||
const client = new Constructor({ token: "test-only-token", fetch: fetcher });
|
||||
await expect(client.call("campaign_delete", {})).rejects.toThrow("not allowed");
|
||||
expect(requests).toHaveLength(0);
|
||||
const result = await client.call("metrics_catalog", {});
|
||||
expect(result.structuredContent).toEqual({ metric: "spend", value: 7 });
|
||||
expect(requests.filter((r) => r.method === "tools/call")).toEqual([{ method: "tools/call", authorization: "Bearer test-only-token" }]);
|
||||
expect(JSON.stringify(result)).not.toContain("test-only-token");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("fails closed without credentials", async () => {
|
||||
const Constructor = Reflect.get(harness, "SwadsMcpClient");
|
||||
expect(Constructor).toBeDefined();
|
||||
const client = new Constructor({ token: "" });
|
||||
await expect(client.call("metrics_catalog", {})).rejects.toThrow("SWADS_MCP_TOKEN");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,14 @@ describe("mock_ads_metrics", () => {
|
||||
});
|
||||
|
||||
describe("tool policy", () => {
|
||||
it("requires approval for real analytics and report generation but never allows arbitrary MCP mutations", () => {
|
||||
expect(decideToolPolicy("swads_whoami")).toBe("allow");
|
||||
expect(decideToolPolicy("metrics_catalog")).toBe("allow");
|
||||
expect(decideToolPolicy("metrics_semantic_query")).toBe("ask");
|
||||
expect(decideToolPolicy("generate_minis_report")).toBe("ask");
|
||||
expect(decideToolPolicy("campaign_update")).toBe("deny");
|
||||
});
|
||||
|
||||
it("allows only the explicit safe set, asks for report save, and denies unknown tools", () => {
|
||||
expect(["read", "grep", "find", "ls", "mock_ads_metrics"].map(decideToolPolicy)).toEqual([
|
||||
"allow", "allow", "allow", "allow", "allow",
|
||||
|
||||
Reference in New Issue
Block a user