11import assert from "node:assert/strict" ;
22import { join } from "node:path" ;
3- import { chmod , mkdtemp , mkdir , readFile , writeFile } from "node:fs/promises" ;
3+ import { chmod , mkdtemp , mkdir , readFile , realpath , writeFile } from "node:fs/promises" ;
44import { tmpdir } from "node:os" ;
55import { afterEach , test } from "vitest" ;
66import {
@@ -26,24 +26,148 @@ function createRequest(
2626 executorId : TaskExecutionRequest [ "executor" ] [ "executorId" ] ,
2727 options : { model ?: string ; provider ?: string ; readOnly ?: boolean } = { } ,
2828) : TaskExecutionRequest {
29+ const workspaceId = "workspace-1" ;
30+ const repositoryId = "repo-1" ;
2931 return {
3032 protocolVersion : PROTOCOL_VERSION ,
3133 taskId : `task-${ executorId } ` ,
3234 taskType : "triage" ,
33- project : { id : "p1" , name : "repo" } ,
34- workItem : { kind : "github-issue" , externalId : "1" , title : "Smoke test" } ,
35- workspace : {
36- sourceRepoPath : "/tmp/repo" ,
37- workBranch : `devagent/${ executorId } /task` ,
38- isolation : "temp-copy" ,
39- readOnly : options . readOnly ,
35+ workspaceRef : {
36+ id : workspaceId ,
37+ name : "Adapter Workspace" ,
38+ provider : "github" ,
39+ primaryRepositoryId : repositoryId ,
4040 } ,
41+ repositories : [ {
42+ id : repositoryId ,
43+ workspaceId,
44+ alias : "primary" ,
45+ name : "repo" ,
46+ repoRoot : "/tmp/repo" ,
47+ repoFullName : "org/repo" ,
48+ defaultBranch : "main" ,
49+ provider : "github" ,
50+ } ] ,
51+ workItem : {
52+ id : "issue-1" ,
53+ kind : "github-issue" ,
54+ externalId : "1" ,
55+ title : "Smoke test" ,
56+ repositoryId,
57+ } ,
58+ execution : {
59+ primaryRepositoryId : repositoryId ,
60+ repositories : [ {
61+ repositoryId,
62+ alias : "primary" ,
63+ sourceRepoPath : "/tmp/repo" ,
64+ workBranch : `devagent/${ executorId } /task` ,
65+ isolation : "temp-copy" ,
66+ readOnly : options . readOnly ,
67+ } ] ,
68+ } ,
69+ targetRepositoryIds : [ repositoryId ] ,
4170 executor : {
4271 executorId,
4372 model : options . model ?? "test-model" ,
4473 provider : options . provider ,
4574 } ,
4675 constraints : { } ,
76+ capabilities : {
77+ canSyncTasks : true ,
78+ canCreateTask : true ,
79+ canComment : true ,
80+ canReview : true ,
81+ canMerge : true ,
82+ canOpenReviewable : true ,
83+ } ,
84+ context : { summary : "smoke" } ,
85+ expectedArtifacts : [ "triage-report" ] ,
86+ } ;
87+ }
88+
89+ function createMultiRepoRequest (
90+ executorId : TaskExecutionRequest [ "executor" ] [ "executorId" ] ,
91+ options : { primaryReadOnly ?: boolean ; secondaryReadOnly ?: boolean ; model ?: string ; provider ?: string } = { } ,
92+ ) : TaskExecutionRequest {
93+ const workspaceId = "workspace-1" ;
94+ const primaryRepositoryId = "repo-1" ;
95+ const secondaryRepositoryId = "repo-2" ;
96+ return {
97+ protocolVersion : PROTOCOL_VERSION ,
98+ taskId : `task-${ executorId } -multi` ,
99+ taskType : "triage" ,
100+ workspaceRef : {
101+ id : workspaceId ,
102+ name : "Adapter Workspace" ,
103+ provider : "github" ,
104+ primaryRepositoryId,
105+ } ,
106+ repositories : [
107+ {
108+ id : primaryRepositoryId ,
109+ workspaceId,
110+ alias : "primary" ,
111+ name : "repo" ,
112+ repoRoot : "/tmp/repo" ,
113+ repoFullName : "org/repo" ,
114+ defaultBranch : "main" ,
115+ provider : "github" ,
116+ } ,
117+ {
118+ id : secondaryRepositoryId ,
119+ workspaceId,
120+ alias : "secondary" ,
121+ name : "repo-support" ,
122+ repoRoot : "/tmp/repo-support" ,
123+ repoFullName : "org/repo-support" ,
124+ defaultBranch : "main" ,
125+ provider : "github" ,
126+ } ,
127+ ] ,
128+ workItem : {
129+ id : "issue-1" ,
130+ kind : "github-issue" ,
131+ externalId : "1" ,
132+ title : "Smoke test" ,
133+ repositoryId : primaryRepositoryId ,
134+ } ,
135+ execution : {
136+ primaryRepositoryId,
137+ repositories : [
138+ {
139+ repositoryId : primaryRepositoryId ,
140+ alias : "primary" ,
141+ sourceRepoPath : "/tmp/repo" ,
142+ workBranch : `devagent/${ executorId } /task` ,
143+ isolation : "temp-copy" ,
144+ readOnly : options . primaryReadOnly ,
145+ } ,
146+ {
147+ repositoryId : secondaryRepositoryId ,
148+ alias : "secondary" ,
149+ sourceRepoPath : "/tmp/repo-support" ,
150+ workBranch : `devagent/${ executorId } /task` ,
151+ isolation : "temp-copy" ,
152+ readOnly : options . secondaryReadOnly ,
153+ } ,
154+ ] ,
155+ } ,
156+ targetRepositoryIds : [ primaryRepositoryId ] ,
157+ executor : {
158+ executorId,
159+ model : options . model ?? "test-model" ,
160+ provider : options . provider ,
161+ } ,
162+ constraints : { } ,
163+ capabilities : {
164+ canSyncTasks : true ,
165+ canCreateTask : true ,
166+ canComment : true ,
167+ canReview : true ,
168+ canMerge : true ,
169+ canOpenReviewable : true ,
170+ } ,
47171 context : { summary : "smoke" } ,
48172 expectedArtifacts : [ "triage-report" ] ,
49173 } ;
@@ -63,9 +187,10 @@ async function collectEvents(
63187 request : TaskExecutionRequest ,
64188 workspacePath : string ,
65189 artifactDir : string ,
190+ repositoryPaths : Record < string , string > = { } ,
66191) : Promise < { events : TaskExecutionEvent [ ] ; result : TaskExecutionResult } > {
67192 const events : TaskExecutionEvent [ ] = [ ] ;
68- const handle = await adapter . launch ( request , workspacePath , artifactDir , ( event ) => {
193+ const handle = await adapter . launch ( request , workspacePath , repositoryPaths , artifactDir , ( event : TaskExecutionEvent ) => {
69194 events . push ( event ) ;
70195 } ) ;
71196 return {
@@ -153,8 +278,9 @@ setTimeout(() => process.exit(0), 10000);
153278 const handle = await new DevAgentAdapter ( `${ process . execPath } ${ stubPath } ` ) . launch (
154279 createRequest ( "devagent" ) ,
155280 workspacePath ,
281+ { } ,
156282 artifactDir ,
157- ( event ) => {
283+ ( event : TaskExecutionEvent ) => {
158284 events . push ( event ) ;
159285 } ,
160286 ) ;
@@ -189,6 +315,40 @@ process.exit(1);
189315 assert . match ( events [ 0 ] ?. type === "log" ? events [ 0 ] . message : "" , / r e s u l t f i l e w a s n e v e r w r i t t e n / ) ;
190316} ) ;
191317
318+ test ( "DevAgentAdapter launches from the prepared primary repository path" , async ( ) => {
319+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
320+ const primaryRepoPath = join ( workspacePath , "repos" , "primary" ) ;
321+ await mkdir ( primaryRepoPath , { recursive : true } ) ;
322+ const stubPath = join ( root , "devagent-cwd-stub.js" ) ;
323+ await createStub ( stubPath , `#!/usr/bin/env node
324+ const fs = require("fs");
325+ const path = require("path");
326+ const args = process.argv.slice(2);
327+ const requestPath = args[args.indexOf("--request") + 1];
328+ const artifactDir = args[args.indexOf("--artifact-dir") + 1];
329+ const request = JSON.parse(fs.readFileSync(requestPath, "utf8"));
330+ const artifactPath = path.join(artifactDir, "triage-report.md");
331+ const resultPath = path.join(artifactDir, "result.json");
332+ fs.writeFileSync(path.join(artifactDir, "cwd.txt"), process.cwd());
333+ fs.writeFileSync(artifactPath, "# Triage\\n\\nStub output\\n");
334+ fs.writeFileSync(resultPath, JSON.stringify({ protocolVersion: "0.1", taskId: request.taskId, status: "success", artifacts: [{ kind: "triage-report", path: artifactPath, createdAt: new Date().toISOString() }], metrics: { startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), durationMs: 1 } }, null, 2));
335+ ` ) ;
336+
337+ const { result } = await collectEvents (
338+ new DevAgentAdapter ( `${ process . execPath } ${ stubPath } ` ) ,
339+ createRequest ( "devagent" ) ,
340+ workspacePath ,
341+ artifactDir ,
342+ { "repo-1" : primaryRepoPath } ,
343+ ) ;
344+
345+ assert . equal ( result . status , "success" ) ;
346+ assert . equal (
347+ await realpath ( ( await readFile ( join ( artifactDir , "cwd.txt" ) , "utf8" ) ) . trim ( ) ) ,
348+ await realpath ( primaryRepoPath ) ,
349+ ) ;
350+ } ) ;
351+
192352test ( "CodexAdapter smoke test with stub executable" , async ( ) => {
193353 const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
194354 const stubPath = join ( root , "codex-stub.js" ) ;
@@ -216,6 +376,58 @@ process.stdout.write("{\\"type\\":\\"turn.completed\\"}\\n");
216376 assert . match ( await readFile ( join ( artifactDir , "triage-report.md" ) , "utf8" ) , / s t u b c o d e x o u t p u t / ) ;
217377} ) ;
218378
379+ test ( "CodexAdapter keeps mixed multi-repo requests writable" , async ( ) => {
380+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
381+ const stubPath = join ( root , "codex-mixed-stub.js" ) ;
382+ await createStub ( stubPath , `#!/usr/bin/env node
383+ const fs = require("fs");
384+ const args = process.argv.slice(2);
385+ const modeIndex = args.indexOf("-s");
386+ if (modeIndex === -1 || args[modeIndex + 1] !== "workspace-write") {
387+ throw new Error("expected workspace-write mode");
388+ }
389+ const outIndex = args.indexOf("-o");
390+ if (outIndex >= 0) fs.writeFileSync(args[outIndex + 1], "stub codex output\\n");
391+ process.stdout.write("{\\"type\\":\\"item.completed\\",\\"item\\":{\\"type\\":\\"agent_message\\",\\"text\\":\\"stub codex output\\"}}\\n");
392+ ` ) ;
393+
394+ process . env . DEVAGENT_RUNNER_CODEX_BIN = `${ process . execPath } ${ stubPath } ` ;
395+ const { result } = await collectEvents (
396+ new CodexAdapter ( ) ,
397+ createMultiRepoRequest ( "codex" , { primaryReadOnly : false , secondaryReadOnly : true } ) ,
398+ workspacePath ,
399+ artifactDir ,
400+ ) ;
401+
402+ assert . equal ( result . status , "success" ) ;
403+ } ) ;
404+
405+ test ( "CodexAdapter keeps fully read-only multi-repo requests read-only" , async ( ) => {
406+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
407+ const stubPath = join ( root , "codex-readonly-stub.js" ) ;
408+ await createStub ( stubPath , `#!/usr/bin/env node
409+ const fs = require("fs");
410+ const args = process.argv.slice(2);
411+ const modeIndex = args.indexOf("-s");
412+ if (modeIndex === -1 || args[modeIndex + 1] !== "read-only") {
413+ throw new Error("expected read-only mode");
414+ }
415+ const outIndex = args.indexOf("-o");
416+ if (outIndex >= 0) fs.writeFileSync(args[outIndex + 1], "stub codex output\\n");
417+ process.stdout.write("{\\"type\\":\\"item.completed\\",\\"item\\":{\\"type\\":\\"agent_message\\",\\"text\\":\\"stub codex output\\"}}\\n");
418+ ` ) ;
419+
420+ process . env . DEVAGENT_RUNNER_CODEX_BIN = `${ process . execPath } ${ stubPath } ` ;
421+ const { result } = await collectEvents (
422+ new CodexAdapter ( ) ,
423+ createMultiRepoRequest ( "codex" , { primaryReadOnly : true , secondaryReadOnly : true } ) ,
424+ workspacePath ,
425+ artifactDir ,
426+ ) ;
427+
428+ assert . equal ( result . status , "success" ) ;
429+ } ) ;
430+
219431test ( "ClaudeAdapter smoke test with stub executable" , async ( ) => {
220432 const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
221433 const stubPath = join ( root , "claude-stub.js" ) ;
@@ -236,6 +448,52 @@ process.stdout.write(JSON.stringify({ type: "result", subtype: "success", result
236448 assert . match ( await readFile ( join ( artifactDir , "triage-report.md" ) , "utf8" ) , / c l a u d e s t u b o u t p u t / ) ;
237449} ) ;
238450
451+ test ( "ClaudeAdapter keeps mixed multi-repo requests writable" , async ( ) => {
452+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
453+ const stubPath = join ( root , "claude-mixed-stub.js" ) ;
454+ await createStub ( stubPath , `#!/usr/bin/env node
455+ const args = process.argv.slice(2);
456+ const permissionIndex = args.indexOf("--permission-mode");
457+ if (permissionIndex === -1 || args[permissionIndex + 1] !== "bypassPermissions") {
458+ throw new Error("expected bypassPermissions");
459+ }
460+ process.stdout.write(JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "claude stub output" }] } }) + "\\n");
461+ process.stdout.write(JSON.stringify({ type: "result", subtype: "success", result: "claude stub output" }) + "\\n");
462+ ` ) ;
463+
464+ const { result } = await collectEvents (
465+ new ClaudeAdapter ( `${ process . execPath } ${ stubPath } ` ) ,
466+ createMultiRepoRequest ( "claude" , { primaryReadOnly : false , secondaryReadOnly : true } ) ,
467+ workspacePath ,
468+ artifactDir ,
469+ ) ;
470+
471+ assert . equal ( result . status , "success" ) ;
472+ } ) ;
473+
474+ test ( "ClaudeAdapter keeps fully read-only multi-repo requests read-only" , async ( ) => {
475+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
476+ const stubPath = join ( root , "claude-readonly-stub.js" ) ;
477+ await createStub ( stubPath , `#!/usr/bin/env node
478+ const args = process.argv.slice(2);
479+ const permissionIndex = args.indexOf("--permission-mode");
480+ if (permissionIndex === -1 || args[permissionIndex + 1] !== "plan") {
481+ throw new Error("expected plan permission mode");
482+ }
483+ process.stdout.write(JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "claude stub output" }] } }) + "\\n");
484+ process.stdout.write(JSON.stringify({ type: "result", subtype: "success", result: "claude stub output" }) + "\\n");
485+ ` ) ;
486+
487+ const { result } = await collectEvents (
488+ new ClaudeAdapter ( `${ process . execPath } ${ stubPath } ` ) ,
489+ createMultiRepoRequest ( "claude" , { primaryReadOnly : true , secondaryReadOnly : true } ) ,
490+ workspacePath ,
491+ artifactDir ,
492+ ) ;
493+
494+ assert . equal ( result . status , "success" ) ;
495+ } ) ;
496+
239497test ( "OpenCodeAdapter smoke test with stub executable" , async ( ) => {
240498 const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
241499 const stubPath = join ( root , "opencode-stub.js" ) ;
@@ -269,6 +527,48 @@ process.stdout.write(JSON.stringify({ type: "step_finish", part: { type: "step-f
269527 assert . match ( await readFile ( join ( artifactDir , "triage-report.md" ) , "utf8" ) , / o p e n c o d e s t u b o u t p u t / ) ;
270528} ) ;
271529
530+ test ( "OpenCodeAdapter keeps mixed multi-repo requests writable" , async ( ) => {
531+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
532+ const stubPath = join ( root , "opencode-mixed-stub.js" ) ;
533+ await createStub ( stubPath , `#!/usr/bin/env node
534+ const permissions = process.env.OPENCODE_PERMISSION || "";
535+ if (permissions.includes('"*":"deny"') || permissions.includes('"write":"deny"') || permissions.includes('"edit":"deny"')) {
536+ throw new Error("expected writable permissions");
537+ }
538+ process.stdout.write(JSON.stringify({ type: "text", part: { type: "text", text: "opencode stub output" } }) + "\\n");
539+ ` ) ;
540+
541+ const { result } = await collectEvents (
542+ new OpenCodeAdapter ( `${ process . execPath } ${ stubPath } ` ) ,
543+ createMultiRepoRequest ( "opencode" , { primaryReadOnly : false , secondaryReadOnly : true } ) ,
544+ workspacePath ,
545+ artifactDir ,
546+ ) ;
547+
548+ assert . equal ( result . status , "success" ) ;
549+ } ) ;
550+
551+ test ( "OpenCodeAdapter keeps fully read-only multi-repo requests read-only" , async ( ) => {
552+ const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
553+ const stubPath = join ( root , "opencode-readonly-stub.js" ) ;
554+ await createStub ( stubPath , `#!/usr/bin/env node
555+ const permissions = process.env.OPENCODE_PERMISSION || "";
556+ if (!permissions.includes('"*":"deny"') || !permissions.includes('"read":"allow"') || !permissions.includes('"edit":"deny"') || !permissions.includes('"write":"deny"')) {
557+ throw new Error("expected read-only permissions");
558+ }
559+ process.stdout.write(JSON.stringify({ type: "text", part: { type: "text", text: "opencode stub output" } }) + "\\n");
560+ ` ) ;
561+
562+ const { result } = await collectEvents (
563+ new OpenCodeAdapter ( `${ process . execPath } ${ stubPath } ` ) ,
564+ createMultiRepoRequest ( "opencode" , { primaryReadOnly : true , secondaryReadOnly : true } ) ,
565+ workspacePath ,
566+ artifactDir ,
567+ ) ;
568+
569+ assert . equal ( result . status , "success" ) ;
570+ } ) ;
571+
272572test ( "OpenCodeAdapter passes provider-qualified model names through" , async ( ) => {
273573 const { root, artifactDir, workspacePath } = await createWorkspace ( ) ;
274574 const stubPath = join ( root , "opencode-model-stub.js" ) ;
0 commit comments