@@ -110,6 +110,33 @@ function createRequest(sourceRepoPath: string, taskId = "task-1"): TaskExecution
110110 } ;
111111}
112112
113+ function createMultiRepoRequest (
114+ primarySourceRepoPath : string ,
115+ secondarySourceRepoPath : string ,
116+ taskId = "task-multi" ,
117+ ) : TaskExecutionRequest {
118+ const request = createRequest ( primarySourceRepoPath , taskId ) ;
119+ request . repositories . push ( {
120+ id : "repo-2" ,
121+ workspaceId : request . workspaceRef . id ,
122+ alias : "secondary" ,
123+ name : "secondary" ,
124+ repoRoot : secondarySourceRepoPath ,
125+ repoFullName : "example/secondary" ,
126+ defaultBranch : "main" ,
127+ provider : "github" ,
128+ } ) ;
129+ request . execution . repositories . push ( {
130+ repositoryId : "repo-2" ,
131+ alias : "secondary" ,
132+ sourceRepoPath : secondarySourceRepoPath ,
133+ workBranch : "devagent/workflow/shared-branch" ,
134+ isolation : "temp-copy" ,
135+ readOnly : true ,
136+ } ) ;
137+ return request ;
138+ }
139+
113140class StaticHandle implements RunHandle {
114141 constructor (
115142 readonly id : string ,
@@ -260,6 +287,70 @@ class RunnerFinalizedAdapter implements ExecutorAdapter {
260287 }
261288}
262289
290+ class RepositoryMutatingAdapter implements ExecutorAdapter {
291+ constructor (
292+ private readonly id : string ,
293+ private readonly repositoryAliasToMutate : string ,
294+ ) { }
295+
296+ executorId ( ) : string {
297+ return this . id ;
298+ }
299+
300+ canHandle ( ) : boolean {
301+ return true ;
302+ }
303+
304+ handlesFinalEvents ( ) : boolean {
305+ return false ;
306+ }
307+
308+ async launch (
309+ request : TaskExecutionRequest ,
310+ _workspacePath : string ,
311+ repositoryPaths : Record < string , string > ,
312+ artifactDir : string ,
313+ onEvent : ( event : TaskExecutionEvent ) => void ,
314+ ) : Promise < RunHandle > {
315+ onEvent ( {
316+ protocolVersion : PROTOCOL_VERSION ,
317+ type : "started" ,
318+ at : new Date ( ) . toISOString ( ) ,
319+ taskId : request . taskId ,
320+ } ) ;
321+
322+ const targetRepository = request . execution . repositories . find ( ( repository ) => repository . alias === this . repositoryAliasToMutate ) ;
323+ if ( ! targetRepository ) {
324+ throw new Error ( `Missing repository alias ${ this . repositoryAliasToMutate } ` ) ;
325+ }
326+ const repositoryPath = repositoryPaths [ targetRepository . repositoryId ] ;
327+ if ( ! repositoryPath ) {
328+ throw new Error ( `Missing workspace path for ${ targetRepository . repositoryId } ` ) ;
329+ }
330+ await writeFile ( join ( repositoryPath , "README.md" ) , `mutated ${ this . repositoryAliasToMutate } \n` ) ;
331+
332+ const artifactPath = join ( artifactDir , "triage-report.md" ) ;
333+ await writeFile ( artifactPath , "# Triage\n" ) ;
334+ const artifact : ArtifactRef = {
335+ kind : "triage-report" ,
336+ path : artifactPath ,
337+ createdAt : new Date ( ) . toISOString ( ) ,
338+ } ;
339+
340+ return new StaticHandle ( `run-${ this . id } -${ this . repositoryAliasToMutate } ` , Promise . resolve ( {
341+ protocolVersion : PROTOCOL_VERSION ,
342+ taskId : request . taskId ,
343+ status : "success" ,
344+ artifacts : [ artifact ] ,
345+ metrics : {
346+ startedAt : new Date ( ) . toISOString ( ) ,
347+ finishedAt : new Date ( ) . toISOString ( ) ,
348+ durationMs : 1 ,
349+ } ,
350+ } ) ) ;
351+ }
352+ }
353+
263354class SleepHandle implements RunHandle {
264355 private readonly done = new EventEmitter ( ) ;
265356 private resolved = false ;
@@ -633,7 +724,7 @@ test("local runner finalizes artifact and completed events for structured adapte
633724test ( "local runner fails non-devagent executors that modify read-only workspaces" , async ( ) => {
634725 const repo = await createRepo ( ) ;
635726 const runner = new LocalRunner ( {
636- adapters : [ new RunnerFinalizedAdapter ( true ) ] ,
727+ adapters : [ new RepositoryMutatingAdapter ( "codex" , "primary" ) ] ,
637728 } ) ;
638729 const request = createRequest ( repo , "task-readonly-violation" ) ;
639730 request . executor . executorId = "codex" ;
@@ -645,3 +736,49 @@ test("local runner fails non-devagent executors that modify read-only workspaces
645736 assert . equal ( result . status , "failed" ) ;
646737 assert . equal ( result . error ?. message , "Executor codex modified a read-only workspace." ) ;
647738} ) ;
739+
740+ test ( "local runner fails devagent executors that modify read-only workspaces" , async ( ) => {
741+ const repo = await createRepo ( ) ;
742+ const runner = new LocalRunner ( {
743+ adapters : [ new RepositoryMutatingAdapter ( "devagent" , "primary" ) ] ,
744+ } ) ;
745+ const request = createRequest ( repo , "task-readonly-violation-devagent" ) ;
746+ request . execution . repositories [ 0 ] ! . readOnly = true ;
747+
748+ const { runId } = await runner . startTask ( request ) ;
749+ const result = await runner . awaitResult ( runId ) ;
750+
751+ assert . equal ( result . status , "failed" ) ;
752+ assert . equal ( result . error ?. message , "Executor devagent modified a read-only workspace." ) ;
753+ } ) ;
754+
755+ test ( "local runner allows writable repo changes while still protecting readonly repos" , async ( ) => {
756+ const primaryRepo = await createRepo ( ) ;
757+ const secondaryRepo = await createRepo ( ) ;
758+ const runner = new LocalRunner ( {
759+ adapters : [ new RepositoryMutatingAdapter ( "devagent" , "primary" ) ] ,
760+ } ) ;
761+ const request = createMultiRepoRequest ( primaryRepo , secondaryRepo , "task-readonly-mixed-primary" ) ;
762+ request . execution . repositories [ 0 ] ! . readOnly = false ;
763+
764+ const { runId } = await runner . startTask ( request ) ;
765+ const result = await runner . awaitResult ( runId ) ;
766+
767+ assert . equal ( result . status , "success" ) ;
768+ } ) ;
769+
770+ test ( "local runner fails when a readonly secondary repo is modified" , async ( ) => {
771+ const primaryRepo = await createRepo ( ) ;
772+ const secondaryRepo = await createRepo ( ) ;
773+ const runner = new LocalRunner ( {
774+ adapters : [ new RepositoryMutatingAdapter ( "devagent" , "secondary" ) ] ,
775+ } ) ;
776+ const request = createMultiRepoRequest ( primaryRepo , secondaryRepo , "task-readonly-mixed-secondary" ) ;
777+ request . execution . repositories [ 0 ] ! . readOnly = false ;
778+
779+ const { runId } = await runner . startTask ( request ) ;
780+ const result = await runner . awaitResult ( runId ) ;
781+
782+ assert . equal ( result . status , "failed" ) ;
783+ assert . equal ( result . error ?. message , "Executor devagent modified a read-only workspace." ) ;
784+ } ) ;
0 commit comments